diff --git a/spec/ReflectingMistModifiers.lua b/spec/ReflectingMistModifiers.lua new file mode 100644 index 00000000000..e5949442bcb --- /dev/null +++ b/spec/ReflectingMistModifiers.lua @@ -0,0 +1,84 @@ +-- Enumerate the exported modifier sources used by the jewellery crafting menus. +local entries, seen = {}, {} +local bases = {} +for name, base in pairs(data.itemBases) do + if base.type == "Ring" or base.type == "Amulet" then + table.insert(bases, { name = name, base = base }) + end +end +table.sort(bases, function(a, b) return a.name < b.name end) + +local function add(source, id, mod, itemType, influenced) + local key = source .. ":" .. tostring(id) .. ":" .. itemType + if not mod or #mod == 0 or seen[key] then return end + seen[key] = true + table.insert(entries, { + source = source, id = tostring(id), mod = mod, itemType = itemType, + baseName = itemType == "Ring" and "Coral Ring" or "Onyx Amulet", + influenced = influenced or false, + }) +end + +local function collectAvailableModifiers(source, mods, necropolis) + for id, mod in pairs(mods) do + if mod.type == "Prefix" or mod.type == "Suffix" then + for _, itemType in ipairs({ "Ring", "Amulet" }) do + local normal, influenced = false, false + for _, base in ipairs(bases) do + if base.base.type == itemType then + local item = setmetatable({ base = base.base }, common.classes.Item) + local weight = necropolis and item:GetNecropolisModSpawnWeight(mod) or item:GetModSpawnWeight(mod) + normal = normal or weight > 0 + for _, influence in ipairs(itemLib.influenceInfo.all) do item[influence.key] = true end + influenced = influenced or item:GetModSpawnWeight(mod) > 0 + end + end + if normal or influenced then add(source, id, mod, itemType, not normal) end + end + end + end +end + +collectAvailableModifiers("Affix", data.itemMods.Item) +collectAvailableModifiers("Veiled", data.veiledMods) +collectAvailableModifiers("Necropolis", data.necropolisMods, true) +for id, mod in ipairs(data.masterMods) do + for _, itemType in ipairs({ "Ring", "Amulet" }) do + if mod.types[itemType] then add("Bench", id, mod, itemType) end + end +end +for id, essence in pairs(data.essences) do + for _, itemType in ipairs({ "Ring", "Amulet" }) do + local modId = essence.mods[itemType] + if modId then add("Essence", id, data.itemMods.Item[modId], itemType) end + end +end +for _, source in ipairs({ + { "Incursion", "Data/IncursionDropOnly", data.itemMods.Explicit }, + { "Delve", "Data/DelveDropOnly", data.itemMods.Delve }, +}) do + for id, drop in pairs(LoadModule(source[2])) do + for _, itemType in ipairs(drop.categories) do + if itemType == "Ring" or itemType == "Amulet" then add(source[1], id, source[3][id], itemType) end + end + end +end +for id, mod in pairs(data.beastCraft) do + for _, itemType in ipairs({ "Ring", "Amulet" }) do add("Beastcraft", id, mod, itemType) end +end +table.sort(entries, function(a, b) + return a.source .. a.id .. a.itemType < b.source .. b.id .. b.itemType +end) + +local function makeItem(entry, range) + local raw = "Rarity: Rare\nMist Modifier Test\n" .. entry.baseName .. "\nImplicits: 0\n" + local prefix = "{modGroup:test}{range:" .. range .. "}{" .. entry.mod.type:lower() .. "}" + if entry.source == "Bench" then prefix = prefix .. "{crafted}" end + if entry.mod.modTags and #entry.mod.modTags > 0 then + prefix = prefix .. "{tags:" .. table.concat(entry.mod.modTags, ",") .. "}" + end + for _, line in ipairs(entry.mod) do raw = raw .. prefix .. line .. "\n" end + return new("Item"):Item(raw) +end + +return { entries = entries, makeItem = makeItem } diff --git a/spec/System/TestReflectingMistModifiers_spec.lua b/spec/System/TestReflectingMistModifiers_spec.lua new file mode 100644 index 00000000000..487ac195c27 --- /dev/null +++ b/spec/System/TestReflectingMistModifiers_spec.lua @@ -0,0 +1,151 @@ +describe("Reflecting Mist exported modifier pool", function() + local mist = require("Modules.ReflectingMist") + local catalog = dofile("../spec/ReflectingMistModifiers.lua") + local sources = {} + for _, entry in ipairs(catalog.entries) do sources[entry.source] = true end + -- These descriptions are not fully understood by the existing modifier parser. + -- Keep the exceptions explicit: any additional parsing regression must fail. + local parserGaps = { + ["#% chance to Defend with #% of Armour"] = true, + ["#% of Chaos Damage Leeched by Enemy as Life"] = true, + ["#% of Cold Damage Leeched by Enemy as Life"] = true, + ["#% of Fire Damage Leeched by Enemy as Life"] = true, + ["#% of Lightning Damage Leeched by Enemy as Life"] = true, + ["#% of Physical Damage Leeched by Enemy as Life"] = true, + ["#% of Physical Attack Damage Leeched by Enemy as Life"] = true, + ["#% of Physical Attack Damage Leeched by Enemy as Mana"] = true, + ["Lose # Life for each Ignited Enemy hit with Attacks"] = true, + ["Shock nearby Enemies for # Seconds when you Focus"] = true, + ["Shock yourself for # Seconds when you Focus"] = true, + ["#% of Damage Leeched by Enemy as Life while Focused"] = true, + } + + it("includes every jewellery modifier source offered by the crafting menus", function() + for _, source in ipairs({ "Affix", "Bench", "Essence", "Veiled", "Incursion", "Delve", "Beastcraft" }) do + assert.is_true(sources[source]) + end + -- Necropolis is also enumerated, but its current exported mods only target armour. + assert.is_true(#catalog.entries > 1000) + end) + + for _, entry in ipairs(catalog.entries) do + if entry.source == "Bench" or entry.source == "Essence" then + it("restores missing affix types for " .. entry.source .. ":" .. entry.id .. " on experimented " .. entry.itemType, function() + local bases = entry.itemType == "Ring" + and { "Cogwork Ring", "Geodesic Ring", "Composite Ring", "Manifold Ring", "Ratcheting Ring", "Helical Ring" } + or { "Simplex Amulet", "Focused Amulet" } + for _, baseName in ipairs(bases) do + local base = data.itemBases[baseName] + local _, newlines = base.implicit:gsub("\n", "") + for _, range in ipairs({ 0, 0.5, 1 }) do + local tagged = new("Item"):Item("Rarity: Rare\nMist Bench Test\n" .. baseName .. "\nImplicits: " .. (newlines + 1) .. "\n" .. base.implicit) + tagged.explicitModLines = copyTable(catalog.makeItem(entry, range).explicitModLines) + for _, modLine in ipairs(tagged.explicitModLines) do modLine.custom = entry.source ~= "Bench" end + tagged:BuildAndParseRaw() + local untagged = new("Item"):Item(tagged:BuildRaw():gsub("{prefix}", ""):gsub("{suffix}", "")) + local original = untagged:BuildRaw() + for _, multiplier in ipairs({ 1.6, 2, 2.1 }) do + for _, negative in ipairs({ false, true }) do + local signs = {} + for i in ipairs(tagged.explicitModLines) do signs[i] = negative end + local expected = mist.ReflectItem(tagged, multiplier, signs) + local actual = mist.ReflectItem(untagged, multiplier, signs) + assert.same(expected.modList, actual.modList) + assert.same(actual.modList, new("Item"):Item(actual:BuildRaw()).modList) + end + end + assert.are.equals(original, untagged:BuildRaw()) + end + end + end) + end + it("preserves " .. entry.source .. ":" .. entry.id .. " on " .. entry.itemType .. " through reflection and reload", function() + for _, range in ipairs({ 0, 0.5, 1 }) do + local source = catalog.makeItem(entry, range) + local original = source:BuildRaw() + if entry.influenced then + -- These modifiers require influence, which makes the item ineligible. + for _, influence in ipairs(itemLib.influenceInfo.all) do source[influence.key] = true end + assert.is_false(mist.CanReflect(source)) + else + assert.is_true(mist.CanReflect(source)) + for _, multiplier in ipairs({ 1.3, 1.6, 1.525, 2.1 }) do + for _, negative in ipairs({ false, true }) do + local negatives = {} + for i in ipairs(source.explicitModLines) do negatives[i] = negative end + local reflected = mist.ReflectItem(source, multiplier, negatives) + local loaded = new("Item"):Item(reflected:BuildRaw()) + assert.is_true(loaded.mirrored) + assert.are.equals(multiplier, loaded.reflectionMultiplier) + assert.are.equals(#source.explicitModLines, #reflected.explicitModLines) + assert.are.equals(reflected:BuildRaw(), loaded:BuildRaw()) + assert.same(reflected.modList, loaded.modList) + for i, modLine in ipairs(reflected.explicitModLines) do + -- Unsupported descriptions must remain visible after reload, too. + assert.are.equals(modLine.extra, loaded.explicitModLines[i].extra) + if not source.explicitModLines[i].extra and modLine.extra then + local template = modLine.line:gsub("[%+%-]?%d+%.?%d*", "#") + assert.is_true(parserGaps[template] or false, modLine.line) + end + end + end + end + assert.are.equals(original, source:BuildRaw()) + end + end + end) + end + + it("uses the game's negative descriptions for resource gain and penetration", function() + local cases = { + { "Gain 10 Life per Enemy Hit with Attacks", "Lose 16 Life per Enemy Hit with Attacks" }, + { "Gain 10 Mana per Enemy Killed", "Lose 16 Mana per Enemy Killed" }, + { "Damage Penetrates 10% Fire Resistance", "Your Hits treat Fire Resistance as 16% higher than actual value" }, + { "Damage Penetrates 10% Elemental Resistances", "Damage Penetrates 16% Elemental Resistances" }, + { "Lose 10 Mana per second", "Regenerate 16 Mana per second" }, + { "Adds 6 to 14 Chaos Damage to Attacks", "Adds 9 to 22 Chaos Damage to Attacks against you" }, + } + for _, case in ipairs(cases) do + assert.are.equals(case[2], mist.ReflectModLine({ line = case[1] }, 1.6, true)) + end + end) + + it("preserves every ring and amulet base implicit", function() + for name, base in pairs(data.itemBases) do + if (base.type == "Ring" or base.type == "Amulet") and base.implicit then + local _, newlines = base.implicit:gsub("\n", "") + local source = new("Item"):Item("Rarity: Rare\nMist Implicit Test\n" .. name + .. "\nImplicits: " .. (newlines + 1) .. "\n" .. base.implicit + .. "\n{range:1}+(10-10) to maximum Life") + if mist.CanReflect(source) then + for _, multiplier in ipairs({ 1.3, 1.6, 1.525, 2.1 }) do + local reflected = mist.ReflectItem(source, multiplier, { true }) + assert.are.equals(#source.implicitModLines, #reflected.implicitModLines) + for i, implicit in ipairs(source.implicitModLines) do + assert.are.equals(implicit.line, reflected.implicitModLines[i].line) + assert.same(implicit.modList, reflected.implicitModLines[i].modList) + end + end + end + end + end + end) + + it("rejects jewellery with corrupted or synthesised implicit modifiers", function() + for _, pool in ipairs({ + { mods = data.itemMods.Corrupted, marker = "Corrupted" }, + { mods = data.itemMods.Synthesis, marker = "Synthesised Item" }, + }) do + for _, baseName in ipairs({ "Coral Ring", "Onyx Amulet" }) do + local source = new("Item"):Item("Rarity: Rare\nMist Implicit Test\n" .. baseName) + for _, mod in pairs(pool.mods) do + if source:GetModSpawnWeight(mod) > 0 then + local raw = "Rarity: Rare\nMist Implicit Test\n" .. baseName + .. "\nImplicits: " .. #mod .. "\n" .. table.concat(mod, "\n") .. "\n" .. pool.marker + assert.is_false(mist.CanReflect(new("Item"):Item(raw))) + end + end + end + end + end) +end) diff --git a/spec/System/TestReflectingMist_spec.lua b/spec/System/TestReflectingMist_spec.lua new file mode 100644 index 00000000000..c332b970bfe --- /dev/null +++ b/spec/System/TestReflectingMist_spec.lua @@ -0,0 +1,636 @@ +describe("Reflecting Mist", function() + local mist = require("Modules.ReflectingMist") + local function item(lines, base) + return new("Item"):Item("Rarity: Rare\nMist Test\n" .. (base or "Coral Ring") .. "\nImplicits: 0\n" .. (lines or "")) + end + + it("offers exact current and legacy multiplier steps", function() + for _, case in ipairs({ { false, 1.3, 0.01, 31 }, { true, 1.5, 0.025, 25 } }) do + local list = mist.GetMultipliers(case[1]) + assert.are.equals(case[4], #list) + for i, entry in ipairs(list) do + assert.near(case[2] + (i - 1) * case[3], entry.multiplier, 0.000001) + end + end + end) + + it("parses both outcomes of added physical damage to spells", function() + local source = item("Adds 11 to 34 Physical Damage to Spells") + for _, negative in ipairs({ false, true }) do + local reflected = mist.ReflectItem(source, 1.6, { negative }) + local loaded = new("Item"):Item(reflected:BuildRaw()) + for _, result in ipairs({ reflected, loaded }) do + local line = result.explicitModLines[1] + assert.are.equals(nil, line.extra) + assert.are.equals(2, #line.modList) + for i, stat in ipairs({ "PhysicalMin", "PhysicalMax" }) do + assert.are.equals(stat, line.modList[i].name) + assert.are.equals(({ 17, 54 })[i] * (negative and -1 or 1), line.modList[i].value) + assert.are.equals(KeywordFlag.Spell, line.modList[i].keywordFlags) + end + end + end + end) + + it("restricts reflection to eligible rare jewellery", function() + assert.is_true(mist.CanReflect(item("+50 to maximum Life"))) + assert.is_true(mist.CanReflect(item("{fractured}+50 to maximum Life", "Simplex Amulet"))) + assert.is_false(mist.CanReflect(item("", "Leather Belt"))) + assert.is_false(mist.CanReflect(item("", "Unset Ring"))) + for _, flag in ipairs({ "corrupted", "mirrored", "synthesised", "veiled", "shaper", "elder", "crusader" }) do + local source = item() + source[flag] = true + assert.is_false(mist.CanReflect(source)) + end + for _, rarity in ipairs({ "NORMAL", "MAGIC", "UNIQUE", "RELIC" }) do + local source = item() + source.rarity = rarity + assert.is_false(mist.CanReflect(source)) + end + end) + + it("truncates reflection before applying base and catalyst bonuses", function() + local source = new("Item"):Item([[ +Rarity: Rare +Mist Test +Simplex Amulet +Catalyst: Intrinsic +CatalystQuality: 20 +Implicits: 1 +100% increased Explicit Modifier magnitudes +{range:1}{tags:attribute}+11 to Strength ++1 to Level of all Skill Gems +]]) + local reflected = mist.ReflectItem(source, 1.6, { true }) + assert.are.equals("-17 to Strength", reflected.explicitModLines[1].line) + assert.are.equals(-37, reflected.explicitModLines[1].modList[1].value) + assert.are.equals("+1 to Level of all Skill Gems", reflected.explicitModLines[2].line) + assert.is_true(reflected.mirrored) + assert.is_false(reflected.corrupted or false) + local loaded = new("Item"):Item(reflected:BuildRaw()) + assert.are.equals(-37, loaded.explicitModLines[1].modList[1].value) + assert.are.equals(source:BuildRaw(), new("Item"):Item(source:BuildRaw()):BuildRaw()) + end) + + local experimentedBases = { + { name = "Cogwork Ring", prefixBonus = 0, suffixBonus = 25 }, + { name = "Geodesic Ring", prefixBonus = 25, suffixBonus = 0 }, + { name = "Composite Ring", prefixBonus = 0 }, + { name = "Manifold Ring", prefixBonus = 50, suffixBonus = 0 }, + { name = "Ratcheting Ring", suffixBonus = 0 }, + { name = "Helical Ring", prefixBonus = 0, suffixBonus = 50 }, + { name = "Simplex Amulet", prefixBonus = 100, suffixBonus = 100 }, + { name = "Focused Amulet", prefixBonus = 100, suffixBonus = 100 }, + } + local function experimentedItem(baseName, catalyst, prefix, suffix) + local base = data.itemBases[baseName] + local _, newlines = base.implicit:gsub("\n", "") + local raw = "Rarity: Rare\nMist Test\n" .. baseName .. "\n" + if catalyst then + raw = raw .. "Catalyst: " .. catalyst .. "\nCatalystQuality: 20\n" + end + raw = raw .. "Implicits: " .. (newlines + 1) .. "\n" .. base.implicit .. "\n" + if prefix then raw = raw .. "{prefix}{tags:life}+(11-11) to maximum Life\n" end + if suffix then raw = raw .. "{suffix}{tags:attribute}+(11-11) to Strength\n" end + return new("Item"):Item(raw) + end + + it("checks reflection eligibility for every experimented base", function() + local expectedJewellery = { ["Astrolabe Amulet"] = false } + for _, base in ipairs(experimentedBases) do expectedJewellery[base.name] = true end + local jewellery = {} + for name, base in pairs(data.itemBases) do + if base.tags and base.tags.experimental_base then + local source = experimentedItem(name) + local eligible = expectedJewellery[name] == true + assert.are.equals(eligible, mist.CanReflect(source), name) + if base.type == "Ring" or base.type == "Amulet" then + jewellery[name] = eligible + end + end + end + assert.same(expectedJewellery, jewellery) + end) + + for _, base in ipairs(experimentedBases) do + for _, catalyst in ipairs({ "None", "Sinistral", "Dextral" }) do + it("reflects and reloads " .. base.name .. " with " .. catalyst .. " catalyst at every multiplier", function() + local source = experimentedItem(base.name, catalyst ~= "None" and catalyst or nil, + base.prefixBonus ~= nil, base.suffixBonus ~= nil) + local original = source:BuildRaw() + assert.is_true(mist.CanReflect(source)) + local prepared = mist.PrepareItem(source) + for _, legacy in ipairs({ false, true }) do + for _, entry in ipairs(mist.GetMultipliers(legacy)) do + for negativeGroup = 1, 2 do + local negatives = {} + for i, modLine in ipairs(prepared.explicitModLines) do + negatives[i] = modLine.prefix and negativeGroup == 1 or modLine.suffix and negativeGroup == 2 + end + local reflected = mist.ReflectItem(prepared, entry.multiplier, negatives) + local loaded = new("Item"):Item(reflected:BuildRaw()) + assert.is_true(loaded.mirrored) + assert.are.equals(entry.multiplier, loaded.reflectionMultiplier) + assert.are.equals(source.catalyst, loaded.catalyst) + assert.are.equals(source.catalystQuality, loaded.catalystQuality) + for i, implicit in ipairs(source.implicitModLines) do + assert.are.equals(implicit.line, loaded.implicitModLines[i].line) + end + for i, modLine in ipairs(reflected.explicitModLines) do + local bonus = modLine.prefix and base.prefixBonus or base.suffixBonus + local quality = (modLine.prefix and catalyst == "Sinistral" or modLine.suffix and catalyst == "Dextral") and 20 or 0 + -- Independent integer oracle: truncate the mist first, then add + -- catalyst and base magnitudes before the final truncation. + local misted = math.floor(11 * entry.multiplier) + local expected = math.floor(misted * (100 + bonus + quality) / 100) + if negatives[i] then expected = -expected end + assert.are.equals(expected, modLine.modList[1].value) + assert.are.equals(expected, loaded.explicitModLines[i].modList[1].value) + assert.is_false(modLine.extra or false) + assert.is_false(loaded.explicitModLines[i].extra or false) + end + end + end + end + assert.are.equals(original, source:BuildRaw()) + end) + end + end + + it("inverts ordinary stats and preserves non-scalable numbers", function() + local cases = { + { "+(40-50) to maximum Life", "-80 to maximum Life" }, + { "20% increased Cast Speed", "32% reduced Cast Speed" }, + { "10% reduced Mana Cost of Skills", "16% increased Mana Cost of Skills" }, + { "Non-Channelling Skills have -7 to Total Mana Cost", "Non-Channelling Skills have +11 to Total Mana Cost" }, + { "Grants Level 20 Aspect of the Spider Skill", "Grants Level 20 Aspect of the Spider Skill" }, + { "Can have up to 3 Crafted Modifiers", "Can have up to 3 Crafted Modifiers" }, + } + for _, case in ipairs(cases) do + assert.are.equals(case[2], mist.ReflectModLine({ line = case[1], range = 1 }, 1.6, true)) + end + end) + + it("uses exported inverted descriptions for special negatives", function() + local cases = { + { "Adds 10 to 20 Fire Damage to Attacks", "Adds 16 to 32 Fire Damage to Attacks against you" }, + { "10 to 20 Added Cold Damage per Frenzy Charge", "Adds 16 to 32 Cold Damage to Hits against you per Frenzy Charge" }, + { "Regenerate 10 Life per second", "Lose 16 Life per second" }, + { "0.5% of Physical Attack Damage Leeched as Life", "0.8% of Physical Attack Damage Leeched by Enemy as Life" }, + { "Shock nearby Enemies for 4 Seconds when you Focus", "Shock yourself for 6.4 Seconds when you Focus" }, + { "10% chance to gain a Frenzy Charge on Kill", "16% chance to lose a Frenzy Charge on Kill" }, + { "Gain 10% of Physical Damage as Extra Fire Damage", "Hits against you gain 16% of Physical Damage as Extra Fire Damage" }, + { "You and your Minions prevent +60% of Reflected Damage", "You and your Minions take 96% increased Reflected Damage" }, + } + for _, case in ipairs(cases) do + assert.are.equals(case[2], mist.ReflectModLine({ line = case[1] }, 1.6, true)) + end + end) + + it("preserves every beast Aspect and modifiers without negative versions", function() + local lines = { + "Grants Level 20 Aspect of the Avian Skill", + "Grants Level 20 Aspect of the Cat Skill", + "Grants Level 30 Aspect of the Crab Skill", + "Grants Level 30 Aspect of the Spider Skill", + "Can have up to 3 Crafted Modifiers", + "Warcries cannot Exert Travel Skills", + "Light Radius is based on Energy Shield instead of Life", + } + for _, legacy in ipairs({ false, true }) do + for _, entry in ipairs(mist.GetMultipliers(legacy)) do + for _, line in ipairs(lines) do + for _, negative in ipairs({ false, true }) do + assert.are.equals(line, mist.ReflectModLine({ line = line }, entry.multiplier, negative)) + end + end + end + end + local source = experimentedItem("Simplex Amulet", "Imbued") + for _, line in ipairs(lines) do + table.insert(source.explicitModLines, { line = line, modTags = { "caster" } }) + end + source:BuildAndParseRaw() + local negatives = {} + for i = 1, #lines do negatives[i] = true end + local reflected = mist.ReflectItem(source, 2.1, negatives) + local loaded = new("Item"):Item(reflected:BuildRaw()) + local groups = mist.GetModGroups(source) + for i, line in ipairs(lines) do + assert.are.equals(line, loaded.explicitModLines[i].line) + assert.is_false(groups[i].canInvert or false) + end + end) + + it("preserves explicitly unscalable values and their selected rolls", function() + for _, line in ipairs({ + "{unscalable}+11 to maximum Life", + "{unscalable}{range:0.25}+(11-19) to maximum Life", + }) do + local source = item(line) + local expected = itemLib.applyRange(source.explicitModLines[1].line, source.explicitModLines[1].range, 1) + for _, multiplier in ipairs({ 1.3, 1.6, 1.525, 2.1 }) do + for _, negative in ipairs({ false, true }) do + local reflected = mist.ReflectItem(source, multiplier, { negative }) + local loaded = new("Item"):Item(reflected:BuildRaw()) + assert.is_true(loaded.explicitModLines[1].unscalable) + assert.are.equals(expected, itemLib.applyRange(loaded.explicitModLines[1].line, loaded.explicitModLines[1].range, 1)) + assert.is_false(mist.GetModGroups(source)[1].canInvert or false) + end + end + end + end) + + it("shows neutral states for Aspects and unscalable modifiers instead of sign buttons", function() + newBuild() + build.itemsTab:SetDisplayItem(item("Grants Level 20 Aspect of the Spider Skill\n{unscalable}+11 to maximum Life\n+30% to Fire Resistance")) + mist.OpenPopup(build.itemsTab) + local controls = main.popups[1].controls + assert.are.equals(nil, controls.positive1) + assert.are.equals(nil, controls.negative1) + assert.are.equals(nil, controls.positive2) + assert.are.equals(nil, controls.negative2) + assert.are.equals("^8Cannot be reflected", controls.state1.label) + assert.are.equals("^8Cannot be reflected", controls.state2.label) + assert.is_false(controls.state1:IsEnabled()) + assert.is_false(controls.state2:IsEnabled()) + local viewPort = { x = 0, y = 0, width = 1920, height = 1080 } + main.popups[1]:Draw(viewPort) + assert.is_true(controls.negative3:IsEnabled()) + controls.negative3:Click() + controls.multiplier:SetVal(1) + assert.is_truthy(controls.mod1.label:find("Grants Level 20 Aspect of the Spider Skill", 1, true)) + assert.is_truthy(controls.mod2.label:find("+11 to maximum Life", 1, true)) + assert.is_truthy(controls.mod3.label:find("-48% to Fire Resistance", 1, true)) + controls.close.onClick() + end) + + it("distinguishes a scalable modifier without a negative version from an unchanged modifier", function() + newBuild() + build.itemsTab:SetDisplayItem(item("Damage Penetrates 10% Elemental Resistances")) + mist.OpenPopup(build.itemsTab) + local controls = main.popups[1].controls + assert.are.equals(nil, controls.positive1) + assert.are.equals(nil, controls.negative1) + assert.are.equals("^8No negative version", controls.state1.label) + main.popups[1]:Draw({ x = 0, y = 0, width = 1920, height = 1080 }) + controls.multiplier:SetVal(1) + assert.is_truthy(controls.mod1.label:find("Damage Penetrates 16% Elemental Resistances", 1, true)) + controls.close.onClick() + end) + + it("keeps hybrid modifier signs together", function() + local source = item("{modGroup:hybrid}+40 to maximum Life\n{modGroup:hybrid}Regenerate 4 Mana per second\n+30% to Fire Resistance") + local groups = mist.GetModGroups(source) + assert.are.equals(2, #groups) + assert.same({ 1, 2 }, groups[1].indices) + local reflected = mist.ReflectItem(source, 1.5, { true, true }) + assert.are.equals("-60 to maximum Life", reflected.explicitModLines[1].line) + assert.are.equals("Lose 6 Mana per second", reflected.explicitModLines[2].line) + assert.are.equals("+45% to Fire Resistance", reflected.explicitModLines[3].line) + end) + + it("keeps prefix and suffix markers when adding modifiers through the editor", function() + newBuild() + for _, sourceId in ipairs({ "MASTER", "ESSENCE", "VEILED", "PREFIX", "SUFFIX", "INCURSION", "DELVE" }) do + build.itemsTab:SetDisplayItem(experimentedItem("Helical Ring")) + build.itemsTab:AddCustomModifierToDisplayItem() + local controls = main.popups[1].controls + local found = false + for i, source in ipairs(controls.source.list) do + if source.sourceId == sourceId then controls.source:SetSel(i); found = true; break end + end + assert.is_true(found) + local mod = controls.modSelect.list[controls.modSelect.selIndex].mod + assert.is_true(mod.type == "Prefix" or mod.type == "Suffix") + controls.save.onClick() + for _, line in ipairs(build.itemsTab.displayItem.explicitModLines) do + assert.are.equals(mod.type == "Prefix", line.prefix or false) + assert.are.equals(mod.type == "Suffix", line.suffix or false) + end + end + end) + + it("applies Helical suffix magnitudes to reflected bench minimum charges", function() + for _, charge in ipairs({ "Endurance", "Frenzy", "Power" }) do + for _, crafted in ipairs({ false, true }) do + local source = experimentedItem("Helical Ring") + table.insert(source.explicitModLines, { line = "+1 to Minimum " .. charge .. " Charges", crafted = true }) + source.crafted = crafted + source:BuildAndParseRaw() + local original = source:BuildRaw() + assert.are.equals(1, source.explicitModLines[1].modList[1].value) + for _, entry in ipairs(mist.GetMultipliers(true)) do + local reflected = mist.ReflectItem(mist.PrepareItem(source), entry.multiplier, {}) + local loaded = new("Item"):Item(reflected:BuildRaw()) + for _, result in ipairs({ reflected, loaded }) do + local mod = result.explicitModLines[1] + assert.is_true(mod.suffix) + assert.is_true(mod.crafted) + assert.are.equals(charge .. "ChargesMin", mod.modList[1].name) + assert.are.equals(entry.multiplier >= 2 and 3 or 1, mod.modList[1].value) + end + end + assert.are.equals(original, source:BuildRaw()) + end + end + end) + + it("previews and saves three crafted minimum charges on a legacy Helical reflection", function() + newBuild() + local source = experimentedItem("Helical Ring") + table.insert(source.explicitModLines, { line = "+1 to Minimum Endurance Charges", crafted = true }) + source:BuildAndParseRaw() + build.itemsTab:SetDisplayItem(source) + mist.OpenPopup(build.itemsTab) + local controls = main.popups[1].controls + controls.legacy.state = true + controls.legacy.changeFunc(true) + controls.multiplier:SetVal(19 / 24) + assert.is_truthy(controls.mod1.label:find("+1 to Minimum Endurance Charges", 1, true)) + controls.multiplier:SetVal(20 / 24) + assert.is_truthy(controls.mod1.label:find("+3 to Minimum Endurance Charges", 1, true)) + controls.save.onClick() + local itemsTab = build.itemsTab + local saved = itemsTab.items[itemsTab.itemOrderList[#itemsTab.itemOrderList]] + assert.are.equals(3, saved.explicitModLines[1].modList[1].value) + assert.are.equals(3, new("Item"):Item(saved:BuildRaw()).explicitModLines[1].modList[1].value) + assert.are.equals(source, itemsTab.displayItem) + end) + + it("separates crafted hybrid affixes before choosing signs", function() + local source = item("Crafted: true\nPrefix: {range:1}IncreasedLife1\nPrefix: {range:1}IncreasedManaEnhancedLevel50ModRegen") + source:Craft() + local prepared = mist.PrepareItem(source) + local groups = mist.GetModGroups(prepared) + assert.are.equals(2, #groups) + assert.are.equals(2, #groups[2].indices) + local negatives = {} + for _, index in ipairs(groups[2].indices) do negatives[index] = true end + local reflected = mist.ReflectItem(prepared, 1.5, negatives) + assert.are.equals("+36 to maximum Life", reflected.explicitModLines[1].line) + assert.are.equals("-117 to maximum Mana", reflected.explicitModLines[2].line) + assert.are.equals("Lose 10.5 Mana per second", reflected.explicitModLines[3].line) + end) + + it("preserves implicits, enchantments and fractured modifiers", function() + local source = new("Item"):Item([[ +Rarity: Rare +Mist Test +Coral Ring +Implicits: 2 +{enchant}Allocates Beef ++30 to maximum Life +{fractured}+50 to maximum Life ++]]) + local reflected = mist.ReflectItem(source, 2.1, { true }) + assert.are.equals(source.implicitModLines[1].line, reflected.implicitModLines[1].line) + assert.are.equals(source.enchantModLines[1].line, reflected.enchantModLines[1].line) + assert.is_true(reflected.explicitModLines[1].fractured) + assert.are.equals("-105 to maximum Life", reflected.explicitModLines[1].line) + end) + + it("keeps long custom modifier previews inside the popup", function() + newBuild() + local text = "A long custom modifier " .. string.rep("with several short words ", 8) + .. string.rep("W", 150) + build.itemsTab:SetDisplayItem(item(text)) + local originalWidth = DrawStringWidth + local ok, err = pcall(function() + -- The headless renderer does not measure fonts. Supply a nonzero glyph + -- width so this test catches both late word breaks and unbroken words. + -- Set the real global because Busted isolates assignments within specs. + _G.DrawStringWidth = function(_, _, value) + local _, glyphs = StripEscapes(value):gsub("[%z\1-\127\194-\244][\128-\191]*", "") + return glyphs * 8 + end + mist.OpenPopup(build.itemsTab) + local popup = main.popups[1] + local controls = popup.controls + local count = 0 + local reconstructed = {} + for line in controls.mod1.label:gmatch("[^\n]+") do + assert.is_true(DrawStringWidth(16, "VAR", line) <= 470) + assert.is_true(controls.mod1.x + DrawStringWidth(16, "VAR", line) < popup.width) + local plain = StripEscapes(line) + -- A continuation byte must never start a wrapped line. + assert.is_false(plain:byte(1) >= 128 and plain:byte(1) < 192) + table.insert(reconstructed, plain) + count = count + 1 + end + assert.is_true(count > 5) + assert.are.equals(text:gsub("%s", ""), table.concat(reconstructed):gsub("%s", "")) + assert.is_true(controls.showComparisons.y >= controls.mod1.y + count * 16) + controls.close.onClick() + end) + _G.DrawStringWidth = originalWidth + assert(ok, err) + end) + + it("compares reflection changes live against the equipped build without changing it", function() + newBuild() + build.controls.characterLevel:SetText("90", true) + local itemsTab = build.itemsTab + for _, life in ipairs({ 50, 100 }) do + itemsTab:SetDisplayItem(item("+" .. life .. " to maximum Life")) + itemsTab:AddDisplayItem() + end + runCallback("OnFrame") + local ring1, ring2 = itemsTab.slots["Ring 1"].selItemId, itemsTab.slots["Ring 2"].selItemId + local source = itemsTab.items[ring2] + itemsTab:SetDisplayItem(source) + local original = source:BuildRaw() + local originalLife = build.calcsTab.mainOutput.Life + mist.OpenPopup(itemsTab) + local popup = main.popups[1] + local controls = popup.controls + local compactHeight = popup.height + assert.is_false(controls.showComparisons.state) + assert.is_false(controls.comparison:IsShown()) + assert.are.equals(0, #controls.comparison.tooltip.lines) + local function hasLifeChange(value) + for _, line in ipairs(controls.comparison.tooltip.lines) do + if line.text and StripEscapes(line.text):find(value .. " Total Life", 1, true) then return true end + end + return false + end + assert.are.equals(nil, controls.compareSlot) + controls.multiplier:SetVal(0) + assert.are.equals(0, #controls.comparison.tooltip.lines) + controls.showComparisons.state = true + controls.showComparisons.changeFunc(true) + assert.is_true(controls.comparison:IsShown()) + assert.is_true(popup.height > compactHeight) + assert.is_true(hasLifeChange("+30")) + assert.is_true(hasLifeChange("+80")) + controls.multiplier:SetVal(1) + assert.is_true(hasLifeChange("+60")) + assert.is_true(hasLifeChange("+110")) + controls.negative1:Click() + assert.is_true(hasLifeChange("-260")) + assert.is_true(hasLifeChange("-210")) + local headers = {} + for _, line in ipairs(controls.comparison.tooltip.lines) do + if line.text and line.text:find("Equipping this item in Ring", 1, true) then table.insert(headers, StripEscapes(line.text)) end + end + assert.are.equals(2, #headers) + assert.is_truthy(table.concat(headers):find("Ring 1", 1, true)) + assert.is_truthy(table.concat(headers):find("Ring 2", 1, true)) + controls.positive1:Click() + assert.is_true(hasLifeChange("+110")) + local normal = new("Tooltip"):Tooltip() + normal.maxWidth = 330 + itemsTab:AddItemStatDifferences(normal, mist.ReflectItem(source, 1.6, {}), source.base) + assert.are.equals(#normal.lines, #controls.comparison.tooltip.lines) + for i, line in ipairs(normal.lines) do + assert.are.equals(line.text, controls.comparison.tooltip.lines[i].text) + end + controls.showComparisons.state = false + controls.showComparisons.changeFunc(false) + assert.is_false(controls.comparison:IsShown()) + assert.are.equals(0, #controls.comparison.tooltip.lines) + assert.are.equals(compactHeight, popup.height) + controls.close.onClick() + assert.are.equals(source, itemsTab.displayItem) + assert.are.equals(original, source:BuildRaw()) + assert.are.equals(ring1, itemsTab.slots["Ring 1"].selItemId) + assert.are.equals(ring2, itemsTab.slots["Ring 2"].selItemId) + assert.are.equals(originalLife, build.calcsTab.mainOutput.Life) + mist.OpenPopup(itemsTab) + controls = main.popups[1].controls + assert.is_false(controls.showComparisons.state) + controls.close.onClick() + end) + + it("compares unequipped amulets and reports unchanged build stats", function() + newBuild() + local itemsTab = build.itemsTab + local source = item("{unscalable}+50 to maximum Life", "Onyx Amulet") + itemsTab:SetDisplayItem(source) + mist.OpenPopup(itemsTab) + local controls = main.popups[1].controls + controls.showComparisons.state = true + controls.showComparisons.changeFunc(true) + assert.are.equals(nil, controls.compareSlot) + local found = false + for _, line in ipairs(controls.comparison.tooltip.lines) do + if line.text and StripEscapes(line.text):find("+50 Total Life", 1, true) then found = true end + end + assert.is_true(found) + controls.close.onClick() + itemsTab:AddDisplayItem() + runCallback("OnFrame") + itemsTab:SetDisplayItem(source) + mist.OpenPopup(itemsTab) + controls = main.popups[1].controls + controls.showComparisons.state = true + controls.showComparisons.changeFunc(true) + assert.are.equals("^7No changes to displayed build stats.", controls.comparison.tooltip.lines[1].text) + controls.multiplier:SetVal(1) + assert.are.equals("^7No changes to displayed build stats.", controls.comparison.tooltip.lines[1].text) + controls.close.onClick() + end) + + for _, saved in ipairs({ false, true }) do + it("saves a reflected copy while preserving the " .. (saved and "equipped" or "unsaved") .. " original", function() + newBuild() + local itemsTab = build.itemsTab + local original = item("+50 to maximum Life") + itemsTab:SetDisplayItem(original) + if saved then + itemsTab:AddDisplayItem() + runCallback("OnFrame") + itemsTab:SetDisplayItem(original) + end + local originalRaw = original:BuildRaw() + local originalId = original.id + local equippedId = itemsTab.slots["Ring 1"].selItemId + local itemCount = #itemsTab.itemOrderList + mist.OpenPopup(itemsTab) + local controls = main.popups[1].controls + assert.is_true(controls.createCopy.state) + controls.multiplier:SetVal(1) + controls.negative1:Click() + controls.save.onClick() + assert.are.equals(itemCount + 1, #itemsTab.itemOrderList) + local copy = itemsTab.items[itemsTab.itemOrderList[#itemsTab.itemOrderList]] + assert.is_true(copy ~= original) + assert.is_true(copy.id ~= originalId) + assert.is_true(copy.mirrored) + assert.are.equals("Reflected " .. original.title, copy.title) + assert.are.equals("Reflected " .. original.name, copy.name) + assert.are.equals(copy.name, new("Item"):Item(copy:BuildRaw()).name) + assert.are.equals(1.6, copy.reflectionMultiplier) + assert.are.equals("-80 to maximum Life", copy.explicitModLines[1].line) + assert.are.equals(original, itemsTab.displayItem) + assert.are.equals(originalRaw, original:BuildRaw()) + assert.are.equals(originalId, original.id) + assert.are.equals(equippedId, itemsTab.slots["Ring 1"].selItemId) + assert.are.equals(0, itemsTab.slots["Ring 2"].selItemId) + if saved then assert.are.equals(original, itemsTab.items[originalId]) end + -- Cancelling a subsequent copy must not add another item. + mist.OpenPopup(itemsTab) + controls = main.popups[1].controls + controls.createCopy.state = true + controls.close.onClick() + assert.are.equals(itemCount + 1, #itemsTab.itemOrderList) + end) + end + + it("updates shared magnitude and individual signs in the popup", function() + newBuild() + local originalDefault = main.defaultItemAffixQuality + main.defaultItemAffixQuality = 0.5 + build.itemsTab:SetDisplayItem(item("+50 to maximum Life\n+30% to Fire Resistance")) + local original = build.itemsTab.displayItem:BuildRaw() + mist.OpenPopup(build.itemsTab) + local popup = main.popups[1] + local controls = popup.controls + assert.are.equals(0.5, controls.multiplier.val) + assert.are.equals("^71.45x", controls.multiplierValue.label) + assert.is_true(controls.positive1.locked) + assert.is_false(controls.negative1.locked) + controls.negative1:Click() + assert.is_false(controls.positive1.locked) + assert.is_true(controls.negative1.locked) + assert.are.equals("^xF05050- Negative", controls.negative1.label) + assert.is_true(controls.positive2.locked) + controls.multiplier:SetVal(0) + assert.are.equals("^71.30x", controls.multiplierValue.label) + assert.is_truthy(controls.mod1.label:find("-65 to maximum Life", 1, true)) + assert.is_truthy(controls.mod2.label:find("+39% to Fire Resistance", 1, true)) + controls.multiplier:SetVal(0.51) + assert.are.equals("^71.45x", controls.multiplierValue.label) + assert.are.equals(0.5, controls.multiplier.val) + controls.positive1:Click() + assert.is_true(controls.positive1.locked) + assert.is_false(controls.negative1.locked) + assert.are.equals(colorCodes.POSITIVE .. "+ Positive", controls.positive1.label) + assert.is_truthy(controls.mod1.label:find("+72 to maximum Life", 1, true)) + controls.negative1:Click() + controls.legacy.state = true + controls.legacy.changeFunc(true) + assert.are.equals("^71.800x", controls.multiplierValue.label) + assert.is_truthy(controls.mod1.label:find("-90 to maximum Life", 1, true)) + controls.multiplier:SetVal(1 / 24 + 0.001) + assert.are.equals("^71.525x", controls.multiplierValue.label) + assert.are.equals(1 / 24, controls.multiplier.val) + controls.legacy.state = false + controls.legacy.changeFunc(false) + assert.are.equals("^71.45x", controls.multiplierValue.label) + controls.close.onClick() + assert.are.equals(original, build.itemsTab.displayItem:BuildRaw()) + main.defaultItemAffixQuality = 0.63 + mist.OpenPopup(build.itemsTab) + controls = main.popups[1].controls + assert.are.equals("^71.49x", controls.multiplierValue.label) + controls.negative2:Click() + controls.createCopy.state = false + controls.save.onClick() + assert.is_true(build.itemsTab.displayItem.mirrored) + assert.are.equals("Mist Test", build.itemsTab.displayItem.title) + assert.are.equals("+74 to maximum Life", build.itemsTab.displayItem.explicitModLines[1].line) + assert.are.equals("-44% to Fire Resistance", build.itemsTab.displayItem.explicitModLines[2].line) + main.defaultItemAffixQuality = originalDefault + end) +end) diff --git a/src/Classes/ButtonControl.lua b/src/Classes/ButtonControl.lua index e231280f5ec..a906aea43da 100644 --- a/src/Classes/ButtonControl.lua +++ b/src/Classes/ButtonControl.lua @@ -44,6 +44,7 @@ function ButtonClass:Draw(viewPort, noTooltip) local enabled = self:IsEnabled() local mOver = self:IsMouseOver() local locked = self:GetProperty("locked") + local backgroundColor = self:GetProperty("backgroundColor") if not enabled then SetDrawColor(0.33, 0.33, 0.33) elseif mOver or locked then @@ -56,6 +57,8 @@ function ButtonClass:Draw(viewPort, noTooltip) SetDrawColor(0, 0, 0) elseif self.clicked and mOver then SetDrawColor(0.5, 0.5, 0.5) + elseif backgroundColor then + SetDrawColor(unpack(backgroundColor)) elseif mOver or locked then SetDrawColor(0.33, 0.33, 0.33) else diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index 1618e93e018..8fa11fcac94 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -501,6 +501,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) -- assume an item to be an advanced copy format if either has mod roll information, a modifier -- line with a range, or advanced copy lines self.advancedCopy = false + self.reflectionMultiplier = nil self.modMagnitudeMods = {} local implicitLines = 0 self.variantList = nil @@ -837,6 +838,8 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) local num = specToNumber(specVal) or self.clusterJewel.maxNodes self.clusterJewelNodeCount = m_min(m_max(num, self.clusterJewel.minNodes), self.clusterJewel.maxNodes) end + elseif specName == "Reflecting Mist" then + self.reflectionMultiplier = specToNumber(specVal) elseif specName == "Catalyst" then for i=1, #catalystList do if specVal == catalystList[i] then @@ -1398,7 +1401,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end end end - if self.advancedCopy or self.crafted then + if self.advancedCopy or self.crafted or self.reflectionMultiplier then -- apply mod magnitude boost to matching mods if #self.modMagnitudeMods > 0 then for _, modMagnitudeMod in ipairs(self.modMagnitudeMods) do @@ -1816,6 +1819,10 @@ function ItemClass:BuildRaw() t_insert(rawLines, "Suffix: " .. (affix.fractured and "{fractured}" or "") .. range .. affix.modId) end end + if self.reflectionMultiplier then + -- Reflected explicit lines store base values, before modifier magnitudes. + t_insert(rawLines, "Reflecting Mist: " .. self.reflectionMultiplier) + end if self.catalyst and self.catalyst > 0 then t_insert(rawLines, "Catalyst: " .. catalystList[self.catalyst]) end diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 0b3852aba58..e84565457df 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -17,6 +17,7 @@ local m_modf = math.modf local buySimilar = require("Classes.CompareBuySimilar") local addImplicit = require("Modules.AddImplicitPopup") +local reflectingMist = require("Modules.ReflectingMist") local gemTooltip = require("Classes.GemTooltip") local rarityDropList = { @@ -568,7 +569,13 @@ holding Shift will put it in the second.]]) self.controls.displayItemCorrupt.shown = function() return self.displayItem and self.displayItem.corruptible end - self.controls.displayItemAddImplicit = new("ButtonControl"):ButtonControl({"TOPLEFT",self.controls.displayItemCorrupt,"TOPRIGHT",true}, {8, 0, 120, 20}, "Add Implicit...", function() + self.controls.displayItemReflect = new("ButtonControl"):ButtonControl({"TOPLEFT",self.controls.displayItemCorrupt,"TOPRIGHT",true}, {8, 0, 100, 20}, "Reflect...", function() + reflectingMist.OpenPopup(self) + end) + self.controls.displayItemReflect.shown = function() + return reflectingMist.CanReflect(self.displayItem) + end + self.controls.displayItemAddImplicit = new("ButtonControl"):ButtonControl({"TOPLEFT",self.controls.displayItemReflect,"TOPRIGHT",true}, {8, 0, 120, 20}, "Add Implicit...", function() addImplicit.AddImplicitToDisplayItem(self, self.displayItem) end) self.controls.displayItemAddImplicit.shown = function() @@ -3808,7 +3815,10 @@ function ItemsTabClass:AddCustomModifierToDisplayItem() local listMod = modList[controls.modSelect.selIndex] if listMod then for _, line in ipairs(listMod.mod) do - t_insert(item.explicitModLines, { line = line, modTags = listMod.mod.modTags, [listMod.type] = true }) + t_insert(item.explicitModLines, { + line = line, modTags = listMod.mod.modTags, [listMod.type] = true, + prefix = listMod.mod.type == "Prefix", suffix = listMod.mod.type == "Suffix", + }) end end end diff --git a/src/Modules/ModParser.lua b/src/Modules/ModParser.lua index bc1f7b97366..072125b2376 100644 --- a/src/Modules/ModParser.lua +++ b/src/Modules/ModParser.lua @@ -133,10 +133,10 @@ local formList = { ["adds (%d+) to (%d+) (%a+) attack damage"] = "DMGATTACKS", ["adds (%d+)%-(%d+) (%a+) attack damage"] = "DMGATTACKS", ["(%d+) to (%d+) added attack (%a+) damage"] = "DMGATTACKS", - ["adds (%d+) to (%d+) (%a+) damage to spells"] = "DMGSPELLS", - ["adds (%d+)%-(%d+) (%a+) damage to spells"] = "DMGSPELLS", - ["adds (%d+) to (%d+) (%a+) spell damage"] = "DMGSPELLS", - ["adds (%d+)%-(%d+) (%a+) spell damage"] = "DMGSPELLS", + ["adds (%-?%d+) to (%-?%d+) (%a+) damage to spells"] = "DMGSPELLS", + ["adds (%-?%d+)%-(%-?%d+) (%a+) damage to spells"] = "DMGSPELLS", + ["adds (%-?%d+) to (%-?%d+) (%a+) spell damage"] = "DMGSPELLS", + ["adds (%-?%d+)%-(%-?%d+) (%a+) spell damage"] = "DMGSPELLS", ["(%d+) to (%d+) added spell (%a+) damage"] = "DMGSPELLS", ["(%d+) to (%d+) spell (%a+) damage"] = "DMGSPELLS", ["adds (%d+) to (%d+) (%a+) damage to attacks and spells"] = "DMGBOTH", diff --git a/src/Modules/ReflectingMist.lua b/src/Modules/ReflectingMist.lua new file mode 100644 index 00000000000..149fd790322 --- /dev/null +++ b/src/Modules/ReflectingMist.lua @@ -0,0 +1,539 @@ +-- Reflecting Mist crafting: one magnitude roll per item, one sign per modifier. +local M = {} + +-- https://www.poewiki.net/wiki/Reflecting_Mist#Mechanics +function M.GetMultipliers(legacy) + local list = {} + local minimum, step, count = 1300, 10, 30 + if legacy then + minimum, step, count = 1500, 25, 24 + end + for i = 0, count do + local value = (minimum + i * step) / 1000 + table.insert(list, { label = string.format(legacy and "%.3fx" or "%.2fx", value), multiplier = value }) + end + return list +end + +function M.CanReflect(item) + if not item or not item.base or item.rarity ~= "RARE" + or (item.base.type ~= "Ring" and item.base.type ~= "Amulet") + or item.corrupted or item.mirrored or item.synthesised or item.veiled + or #(item.sockets or {}) > 0 or (item.base.implicit or ""):find("Has %d+ Socket") then + return false + end + for _, influence in ipairs(itemLib.influenceInfo.all) do + if item[influence.key] then + return false + end + end + return true +end + +local function stripRanges(line) + return line:gsub("%((%-?%d+%.?%d*)%-%-?%d+%.?%d*%)", "%1") +end + +local function lineKey(line) + return stripRanges(line):gsub("([%+%-]?)%d+%.?%d*", function(sign) + return sign == "-" and "-#" or "#" + end):lower() +end + +local function inversionKey(line) + return lineKey(line) + :gsub("^lose ", "regenerate ") + :gsub(" leeched by enemy as ", " leeched as ") + :gsub("shock yourself", "shock nearby enemies") + :gsub("chance to lose (#%% of .+ when you use a skill)", "chance to recover %1") + :gsub("chance to lose ", "chance to gain ") + :gsub("^hits against you gain ", "gain ") + :gsub("^you and your minions take (#%%) increased reflected damage", "you and your minions prevent %1 of reflected damage") + :gsub(" against you", "") + :gsub(" to hits", "") + :gsub("^(# to #) added (.+) damage", "adds %1 %2 damage") +end + +local invertedTemplates +local function getInvertedTemplates() + if invertedTemplates then + return invertedTemplates + end + invertedTemplates = {} + -- Use the game's exported inverted descriptions for regeneration, added damage, + -- leech, Focus and other modifiers whose negative version changes its wording. + for _, mods in ipairs({ data.itemMods.Explicit, data.itemMods.Delve, data.veiledMods }) do + local originals = {} + for id, mod in pairs(mods) do + if type(id) == "string" then originals[id:gsub("_+$", "")] = mod end + end + for id, inverted in pairs(mods) do + local original = type(id) == "string" and id:match("^(.*)Inverted$") + original = original and originals[original] + if original and #original == #inverted then + for _, line in ipairs(original) do + for _, invertedLine in ipairs(inverted) do + -- Match descriptions rather than line positions: inverted hybrids + -- can reorder their stats. Unchanged lines use ordinary negation. + if lineKey(line) ~= lineKey(invertedLine) and inversionKey(line) == inversionKey(invertedLine) then + local template = stripRanges(invertedLine):gsub("([%+%-]?)%d+%.?%d*", "%1#") + invertedTemplates[lineKey(line)] = template + -- Drop-restricted inverted modifiers can also be selected in + -- the crafting editor, so support the reverse transformation. + invertedTemplates[lineKey(invertedLine)] = stripRanges(line):gsub("([%+%-]?)%d+%.?%d*", "%1#") + end + end + end + end + end + end + -- Some bench crafts (notably added Chaos Damage) have an inverted stat + -- description but no corresponding Inverted affix in the exported mod pool. + for line in pairs(data.modScalability) do + if line:find(" to Attacks against you", 1, true) then + local original = line:gsub(" against you", "") + invertedTemplates[lineKey(original)] = line + invertedTemplates[lineKey(line)] = original + end + end + return invertedTemplates +end + +local signedDescriptions +local function invertSignedDescription(line) + if not signedDescriptions then + signedDescriptions = {} + for _, entry in ipairs(LoadModule("Data/StatDescriptions/stat_descriptions")) do + if #entry.stats == 1 then + for _, form in ipairs(entry[1] or {}) do + local text = form.text:gsub("%%%%", "%%") + local _, placeholders = text:gsub("%b{}", "") + if placeholders == 1 and not text:find("\\n", 1, true) then + local limit = form.limit[1] + local negative = type(limit[2]) == "number" and limit[2] < 0 + local positive = type(limit[1]) == "number" and limit[1] > 0 + if negative or positive then + local opposite + for _, candidate in ipairs(entry[1]) do + local bounds = candidate.limit[1] + if (negative and type(bounds[1]) == "number" and bounds[1] > 0) + or (positive and type(bounds[2]) == "number" and bounds[2] < 0) then + opposite = candidate.text:gsub("%%%%", "%%") + break + end + end + local template = text:gsub("%b{}", function(value) + return value:find("+", 1, true) and "+#" or "#" + end) + local key = lineKey(template:gsub("#", "1")) + local pattern = text:gsub("%b{}", "\001"):gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") + :gsub("\001", "([%%+%%-]?%%d+%%.?%%d*)") + signedDescriptions[key] = signedDescriptions[key] or {} + table.insert(signedDescriptions[key], { pattern = "^" .. pattern .. "$", opposite = opposite }) + end + end + end + elseif entry[1] and #entry[1] == 1 then + local form = entry[1][1] + local positiveOnly = true + for _, limit in ipairs(form.limit) do + if type(limit[1]) ~= "number" or limit[1] < 0 then positiveOnly = false end + end + local text = form.text:gsub("%%%%", "%%") + if positiveOnly and text:find("{", 1, true) and not text:find("\\n", 1, true) then + local template = text:gsub("%b{}", "#") + local key = lineKey(template:gsub("#", "1")) + local pattern = text:gsub("%b{}", "\001"):gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") + :gsub("\001", "([%%+%%-]?%%d+%%.?%%d*)") + signedDescriptions[key] = signedDescriptions[key] or {} + table.insert(signedDescriptions[key], { pattern = "^" .. pattern .. "$" }) + end + end + end + end + local matched = false + for _, form in ipairs(signedDescriptions[lineKey(line)] or {}) do + local value = line:match(form.pattern) + if value then + matched = true + if form.opposite then + return form.opposite:gsub("%b{}", function(format) + return (format:find("+", 1, true) and "+" or "") .. tostring(math.abs(tonumber(value))) + end) + end + end + end + -- A stat with only a positive description has no negative outcome. + return matched and line or nil +end + +function M.ReflectModLine(modLine, multiplier, negative) + if modLine.unscalable then + return modLine.line + end + local lines = {} + for line in modLine.line:gmatch("[^\n]+") do + local range = modLine.range or main.defaultItemAffixQuality or 1 + -- Resolve the original roll, then truncate the reflected magnitude in the + -- stat's internal units. Catalyst and base bonuses are applied on reparse. + local scaled = itemLib.applyRange(line, range, multiplier) + if negative then + local template = getInvertedTemplates()[lineKey(scaled)] + if template then + local values = {} + for value in scaled:gmatch("[%+%-]?%d+%.?%d*") do + table.insert(values, tostring(math.abs(tonumber(value)))) + end + local i = 0 + scaled = template:gsub("#", function() + i = i + 1 + return values[i] + end) + elseif itemLib.isModLineScalable(line, range, 1) then + local described = invertSignedDescription(scaled) + if described then + scaled = described + else + scaled = itemLib.applyRange(line, range, -multiplier):gsub("%+%-", "-") + scaled = scaled:gsub("have (%d+%.?%d*) to Total Mana Cost", "have +%1 to Total Mana Cost") + local antonyms = { increased = "reduced", reduced = "increased", more = "less", less = "more" } + scaled = scaled:gsub("%-(%d+%.?%d*%%) (%a+)", function(value, word) + return antonyms[word] and value .. " " .. antonyms[word] or "-" .. value .. " " .. word + end) + end + end + end + table.insert(lines, scaled) + end + return table.concat(lines, "\n") +end + +function M.GetModGroups(item) + local groups, byId = {}, {} + for i, modLine in ipairs(item.explicitModLines) do + if item:CheckModLineVariant(modLine) then + local id = modLine.modGroup or modLine.modId + local group = id and byId[id] + if not group then + group = { indices = {} } + table.insert(groups, group) + if id then byId[id] = group end + end + table.insert(group.indices, i) + if M.ReflectModLine(modLine, 1.6, false) ~= M.ReflectModLine(modLine, 1.6, true) then + group.canInvert = true + end + if M.ReflectModLine(modLine, 1, false) ~= M.ReflectModLine(modLine, 2.1, false) then + group.canScale = true + end + end + end + return groups +end + +function M.PrepareItem(source) + local item = new("Item"):Item(source:BuildRaw()) + item.id = source.id + if item.crafted then + -- The crafting editor merges identical stats from separate affixes. Restore + -- individual affixes so hybrid signs and per-mod rounding remain correct. + local explicits = {} + for _, modLine in ipairs(item.explicitModLines) do + if modLine.crafted or modLine.custom then + table.insert(explicits, modLine) + end + end + for _, affixes in ipairs({ item.prefixes, item.suffixes }) do + for i, affix in ipairs(affixes) do + local mod = item.affixes[affix.modId] + if mod then + for _, line in ipairs(mod) do + table.insert(explicits, { + line = itemLib.applyRange(line, affix.range or 0.5), + modGroup = mod.type .. i, + modTags = mod.modTags, + prefix = mod.type == "Prefix", + suffix = mod.type == "Suffix", + fractured = affix.fractured, + }) + end + end + end + end + item.crafted = false + item.explicitModLines = explicits + item:BuildAndParseRaw() + end + return item +end + +local addedAffixTypes = {} +local function restoreAddedAffixTypes(item) + local missing = false + for _, modLine in ipairs(item.explicitModLines) do + if (modLine.crafted or modLine.custom) and not modLine.prefix and not modLine.suffix then missing = true end + end + if not missing then return end + local types = addedAffixTypes[item.baseName] + if not types then + types = { crafted = {}, custom = {} } + addedAffixTypes[item.baseName] = types + local function register(mod, target) + if not mod or (mod.type ~= "Prefix" and mod.type ~= "Suffix") then return end + for _, line in ipairs(mod) do + local key = lineKey(line) + if target[key] == nil then + target[key] = mod.type + elseif target[key] ~= mod.type then + -- Text alone cannot identify an affix when both types use it. + target[key] = false + end + end + end + for _, craft in ipairs(data.masterMods) do + if craft.types[item.base.type] then register(craft, types.crafted) end + end + for _, mods in ipairs({ item.affixes or {}, data.veiledMods }) do + for _, mod in pairs(mods) do + if (mod.type == "Prefix" or mod.type == "Suffix") and item:GetModSpawnWeight(mod) > 0 then + register(mod, types.custom) + end + end + end + for _, essence in pairs(data.essences) do + register(data.itemMods.Item[essence.mods[item.base.type]], types.custom) + end + for _, source in ipairs({ + { "Data/IncursionDropOnly", data.itemMods.Explicit }, + { "Data/DelveDropOnly", data.itemMods.Delve }, + }) do + for id, drop in pairs(LoadModule(source[1])) do + for _, category in ipairs(drop.categories) do + if category == item.base.type or category == item.base.type .. ": " .. (item.base.subType or "") then + register(source[2][id], types.custom) + break + end + end + end + end + end + for _, modLine in ipairs(item.explicitModLines) do + if (modLine.crafted or modLine.custom) and not modLine.prefix and not modLine.suffix then + local affixType = types[modLine.crafted and "crafted" or "custom"][lineKey(modLine.line)] + if affixType then modLine[affixType:lower()] = true end + end + end +end + +function M.ReflectItem(source, multiplier, negatives) + local item = new("Item"):Item(source:BuildRaw()) + item.id = source.id + -- Older modifiers added through the editor may have no prefix/suffix marker. + -- Recover it before the reflected values receive the base's magnitude bonus. + restoreAddedAffixTypes(item) + -- Keep the resolved explicit lines rather than regenerating the original affixes. + item.crafted = false + item.mirrored = true + item.reflectionMultiplier = multiplier + for i, modLine in ipairs(item.explicitModLines) do + modLine.line = M.ReflectModLine(modLine, multiplier, negatives[i]) + if not modLine.unscalable then + modLine.range = nil + end + end + item:BuildAndParseRaw() + return item +end + +local function wrapModLine(text, size, width) + local lines, line = {}, "" + local function fits(value) + return DrawStringWidth(size, "VAR", value) <= width + end + for word in text:gmatch("%S+") do + local candidate = line == "" and word or line .. " " .. word + if fits(candidate) then + line = candidate + else + if line ~= "" then table.insert(lines, line) end + line = "" + -- Custom modifiers may contain words wider than the entire column. + -- Split those at UTF-8 character boundaries rather than letting them overflow. + for char in word:gmatch("[%z\1-\127\194-\244][\128-\191]*") do + if line ~= "" and not fits(line .. char) then + table.insert(lines, line) + line = "" + end + line = line .. char + end + end + end + if line ~= "" then table.insert(lines, line) end + return lines +end + +function M.OpenPopup(itemsTab) + if not M.CanReflect(itemsTab.displayItem) then return end + local source = M.PrepareItem(itemsTab.displayItem) + local controls, negatives = {}, {} + local groups = M.GetModGroups(source) + local multipliers = M.GetMultipliers(false) + local defaultRoll = math.max(0, math.min(1, main.defaultItemAffixQuality or 0.5)) + local defaultStep = round(defaultRoll * (#multipliers - 1)) + local multiplier = multipliers[defaultStep + 1].multiplier + local preview + local popup + local comparisonTooltip = new("Tooltip"):Tooltip() + local comparisonViewPort = { x = 0, y = 0, width = 685, height = main.screenH } + local popupWidth = 705 + local function updatePreview() + preview = M.ReflectItem(source, multiplier, negatives) + local offset = 75 + for i, group in ipairs(groups) do + local lines = {} + for _, index in ipairs(group.indices) do + local modLine = preview.explicitModLines[index] + local line = itemLib.applyRange(modLine.line, modLine.range or 1, modLine.valueScalar) + for _, wrapped in ipairs(wrapModLine(line, 16, 470)) do + table.insert(lines, "^7" .. wrapped) + end + end + local negative = negatives[group.indices[1]] or false + local positiveButton, negativeButton = controls["positive" .. i], controls["negative" .. i] + local textHeight = #lines * 16 + local contentHeight = math.max(22, textHeight) + local controlY = offset + math.floor((contentHeight - 22) / 2) + if group.canInvert then + positiveButton.y, negativeButton.y = controlY, controlY + positiveButton.locked, negativeButton.locked = not negative, negative + positiveButton.backgroundColor = not negative and { 0.06, 0.14, 0.09 } or { 0.04, 0.04, 0.04 } + negativeButton.backgroundColor = negative and { 0.16, 0.06, 0.08 } or { 0.04, 0.04, 0.04 } + positiveButton.label = (not negative and colorCodes.POSITIVE or "^8") .. "+ Positive" + negativeButton.label = (negative and "^xF05050" or "^8") .. "- Negative" + else + controls["state" .. i].y = controlY + end + controls["mod" .. i].y = offset + math.floor((contentHeight - textHeight) / 2) + controls["mod" .. i].label = table.concat(lines, "\n") + offset = offset + contentHeight + 2 + end + comparisonTooltip:Clear() + controls.showComparisons.y = offset + 10 + offset = offset + 36 + popupWidth = 705 + local height = offset + 36 + if controls.showComparisons.state then + -- Use the normal editor's comparison formatting and slot ordering. Keeping + -- ring sections narrow lets the tooltip place them in columns if needed. + comparisonTooltip.maxWidth = source.base.type == "Ring" and 330 or 660 + itemsTab:AddItemStatDifferences(comparisonTooltip, preview, preview.base) + if #comparisonTooltip.lines == 0 then + comparisonTooltip:AddLine(14, "^7No changes to displayed build stats.") + end + controls.comparison.y = offset + comparisonViewPort.height = math.max(160, main.screenH - offset - 100) + local comparisonWidth, comparisonHeight = comparisonTooltip:GetDynamicSize(comparisonViewPort) + popupWidth = math.max(705, comparisonWidth + 20) + height = height + comparisonHeight + end + if popup then popup.width, popup.height = popupWidth, height end + return height + end + controls.multiplierLabel = new("LabelControl"):LabelControl({ "TOPLEFT", nil, "TOPLEFT" }, { 10, 25, 0, 16 }, "^7Multiplier:") + controls.multiplier = new("SliderControl"):SliderControl({ "TOPLEFT", nil, "TOPLEFT" }, { 90, 22, 180, 20 }, function(val) + local step = round(val * (#multipliers - 1)) + local value = multipliers[step + 1] + controls.multiplier.val = step / (#multipliers - 1) + if multiplier == value.multiplier then return end + multiplier = value.multiplier + controls.multiplierValue.label = "^7" .. value.label + updatePreview() + end) + local function updateSliderSteps() + local step = 1 / (#multipliers - 1) + controls.multiplier.scrollWheelSpeedTbl = { DEFAULT = step, CTRL = step, SHIFT = 5 * step } + end + updateSliderSteps() + controls.multiplier.val = defaultStep / (#multipliers - 1) + controls.multiplierValue = new("LabelControl"):LabelControl({ "TOPLEFT", nil, "TOPLEFT" }, { 280, 25, 0, 16 }, "^7" .. multipliers[defaultStep + 1].label) + controls.legacy = new("CheckBoxControl"):CheckBoxControl({ "TOPLEFT", nil, "TOPLEFT" }, { 360, 24, 18 }, "Legacy (before 3.29)", function(state) + multipliers = M.GetMultipliers(state) + updateSliderSteps() + local step = round(defaultRoll * (#multipliers - 1)) + controls.multiplier.val = step / (#multipliers - 1) + multiplier = multipliers[step + 1].multiplier + controls.multiplierValue.label = "^7" .. multipliers[step + 1].label + updatePreview() + end, "Legacy: 1.50-2.10x in 0.025 steps. Current: 1.30-1.60x in 0.01 steps.", false) + controls.legacy.labelRight = true + controls.hint = new("LabelControl"):LabelControl({ "TOPLEFT", nil, "TOPLEFT" }, { 10, 52, 0, 16 }, "^7Select Positive or Negative for each modifier:") + for i, group in ipairs(groups) do + local function selectSign(negative) + if (negatives[group.indices[1]] or false) == negative then return end + for _, modIndex in ipairs(group.indices) do + negatives[modIndex] = negative + end + updatePreview() + end + if group.canInvert then + controls["positive" .. i] = new("ButtonControl"):ButtonControl({ "TOPLEFT", nil, "TOPLEFT" }, { 10, 75, 95, 22 }, "+ Positive", function() selectSign(false) end) + controls["negative" .. i] = new("ButtonControl"):ButtonControl({ "TOPLEFT", nil, "TOPLEFT" }, { 104, 75, 95, 22 }, "- Negative", function() selectSign(true) end) + else + local state = new("ButtonControl"):ButtonControl({ "TOPLEFT", nil, "TOPLEFT" }, { 10, 75, 189, 22 }, group.canScale and "^8No negative version" or "^8Cannot be reflected") + controls["state" .. i] = state + state.enabled = false + state.tooltipText = group.canScale + and "This modifier has no negative counterpart. Its value still scales with the reflection multiplier." + or "Reflecting Mist leaves this modifier unchanged on both reflected items.\nIt has no negative counterpart and its values do not scale.\nThis does not mean the modifier must roll positive." + end + controls["mod" .. i] = new("LabelControl"):LabelControl({ "TOPLEFT", nil, "TOPLEFT" }, { 215, 75, 0, 16 }, "") + end + controls.showComparisons = new("CheckBoxControl"):CheckBoxControl({ "TOPLEFT", nil, "TOPLEFT" }, { 10, 0, 18 }, "Show stat comparisons", function() + updatePreview() + end, nil, false) + controls.showComparisons.labelRight = true + controls.comparison = new("Control"):Control({ "TOPLEFT", nil, "TOPLEFT" }, { 10, 0 }) + controls.comparison.shown = function() return controls.showComparisons.state end + controls.comparison.tooltip = comparisonTooltip + function controls.comparison:Draw() + local x, y = self:GetPos() + comparisonViewPort.x, comparisonViewPort.y = x, y + -- Reuse the tooltip's text and column layout without its background or border. + local width, height = comparisonTooltip:GetSize() + local _, _, lines = comparisonTooltip:CalculateColumns(y, x, height, width, comparisonViewPort) + SetDrawColor(1, 1, 1) + for _, line in ipairs(lines) do + if #line >= 6 then DrawString(unpack(line)) end + end + end + controls.createCopy = new("CheckBoxControl"):CheckBoxControl({ "TOPLEFT", nil, "TOPLEFT" }, { 520, 24, 18 }, "Create new item", nil, + "Creates the reflected item as a new copy and saves it directly to the build's item list, leaving the original item unchanged.", true) + controls.createCopy.labelRight = true + controls.save = new("ButtonControl"):ButtonControl({ "BOTTOM", nil, "BOTTOM" }, { -45, -4, 80, 20 }, "Reflect", function() + if controls.createCopy.state then + local original = itemsTab.displayItem + preview.id = nil + preview.title = "Reflected " .. preview.title + preview:BuildAndParseRaw() + itemsTab:SetDisplayItem(preview) + itemsTab:AddDisplayItem(true) + itemsTab:SetDisplayItem(original) + else + itemsTab:SetDisplayItem(preview) + end + main:ClosePopup() + end) + controls.save.tooltipFunc = function(tooltip) + tooltip:Clear() + itemsTab:AddItemTooltip(tooltip, preview, nil, false) + end + controls.close = new("ButtonControl"):ButtonControl({ "BOTTOM", nil, "BOTTOM" }, { 45, -4, 80, 20 }, "Cancel", function() + main:ClosePopup() + end) + local height = updatePreview() + popup = main:OpenPopup(popupWidth, height, "Reflect Item", controls) + popup.x = function() return math.floor((main.screenW - popup.width) / 2) end + popup.y = function() return math.floor((main.screenH - popup.height) / 2) end +end + +return M