diff --git a/spec/System/TestCompareBuySimilar_spec.lua b/spec/System/TestCompareBuySimilar_spec.lua new file mode 100644 index 0000000000..8848ad17de --- /dev/null +++ b/spec/System/TestCompareBuySimilar_spec.lua @@ -0,0 +1,89 @@ +describe("Buy similar mod stat matching", function() + local bs = LoadModule("Classes/CompareBuySimilar") + + describe("addModEntries mod matching", function() + it("matches from nothing mods as options", function() + local fromNothing = new("Item", [[ +From Nothing +Diamond +LevelReq: 0 +Radius: Small +Limited to: 1 +Implicits: 0 +Passives in radius of Zealot's Oath can be Allocated without being connected to your tree +Corrupted]]) + + local modSources = { + { list = fromNothing.explicitModLines, type = "explicit" } + } + local modEntries = bs.addModEntries(fromNothing, modSources) + assert.equal(1, #modEntries) + assert.same( + { + formattedLines = { colorCodes.MAGIC.."Passives in radius of Zealot's Oath can be Allocated without being connected to your tree" }, + type = + "explicit", + isOption = true, + invert = false, + tradeIds = { "explicit.stat_2422708892|52" }, + value = nil + }, + modEntries[1]) + end) + + it("combines mods that are the same stat", function() + local lifeDiamond = new("Item", [[ +Test Subject +Diamond +Implicits: 0 ++100 to Maximum Life ++50 to Maximum Life ++50% to Fire Resistance]]) + + local entries = bs.addModEntries(lifeDiamond, { { list = lifeDiamond.explicitModLines, type = "explicit" } }) + assert.equal(2, #entries) + assert.equal(2, #entries[1].formattedLines) + assert.equal("+100 to Maximum Life", StripEscapes(entries[1].formattedLines[1])) + assert.equal("+50 to Maximum Life", StripEscapes(entries[1].formattedLines[2])) + assert.equal(150, entries[1].value) + + local lifelessDiamond = new("Item", [[ +Test Subject +Diamond +Implicits: 0 +-100 to Maximum Life ++50 to Maximum Life ++50% to Fire Resistance]]) + local entries = bs.addModEntries(lifelessDiamond, + { { list = lifelessDiamond.explicitModLines, type = "explicit" } }) + assert.equal(2, #entries) + assert.equal(2, #entries[1].formattedLines) + assert.equal(-50, entries[1].value) + end) + + it("is not case-sensitive", function () + local funnyItem = new("Item", [[ +Test Subject +Diamond +Implicits: 1 ++50 tO MaxIMum lifE]]) + + local entries = bs.addModEntries(funnyItem, {{list = funnyItem.implicitModLines, type = "implicit"}}) + assert.equal(1, #entries) + end) + + it("does not combine implicit and explicit mods", function() + local lifelessDiamond = new("Item", [[ +Test Subject +Diamond +Implicits: 1 +-100 to Maximum Life ++50 to Maximum Life]]) + local entries = bs.addModEntries(lifelessDiamond, + { { list = lifelessDiamond.implicitModLines, type = "implicit" }, { list = lifelessDiamond.explicitModLines, type = "explicit" } }) + assert.equal(2, #entries) + assert.equal(-100, entries[1].value) + assert.equal(50, entries[2].value) + end) + end) +end) diff --git a/spec/System/TestTradeHelpers_spec.lua b/spec/System/TestTradeHelpers_spec.lua new file mode 100644 index 0000000000..3120b01f18 --- /dev/null +++ b/spec/System/TestTradeHelpers_spec.lua @@ -0,0 +1,112 @@ +describe("TradeHelpers trade hash matching", function() + local tradeHelpers = LoadModule("Classes/TradeHelpers") + + ---@param ids number[] + ---@param expected number + ---@return boolean contains whether the given array contains the expected id + local function contains(ids, expected) + for _, id in ipairs(ids) do + if id == expected then return true end + end + return false + end + + describe("modLineValue", function() + it("returns the single number on a line", function() + assert.equal(50, tradeHelpers.modLineValue("+50 to maximum Life")) + end) + + it("returns the midpoint of a '# to #' range", function() + assert.equal(15, tradeHelpers.modLineValue("Adds 10 to 20 Fire Damage")) + assert.equal(12.5, tradeHelpers.modLineValue("Adds 10 to 15 Fire Damage")) + end) + + it("handles negative numbers", function() + assert.equal(-10, tradeHelpers.modLineValue("-10% to Fire Resistance")) + end) + + it("returns nil when onlyFromTo is set and there is no range", function() + assert.is_nil(tradeHelpers.modLineValue("+50 to maximum Life", true)) + end) + end) + + describe("findTradeHash", function() + it("matches a simple mod", function() + local ids, value = tradeHelpers.findTradeHash("+50 to maximum Life") + assert.equal(50, value) + assert.is_true(contains(ids, HashStats({ "base_maximum_life" }))) + end) + + it("matches a percentage mod", function() + local ids, value = tradeHelpers.findTradeHash("25% reduced maximum Energy Shield") + assert.equal(25, value) + assert.is_true(contains(ids, HashStats({ "maximum_energy_shield_+%" }))) + end) + + it("matches a # to # mod", function() + local ids, value = tradeHelpers.findTradeHash("Adds 5 to 15 Fire Damage") + assert.equal(10, value) + assert.is_true(contains(ids, + HashStats({ "local_minimum_added_fire_damage", "local_maximum_added_fire_damage" }))) + end) + + it("is case-insensitive", function() + local ids = tradeHelpers.findTradeHash( + "Each ArroW fIred is a Crescendo, Splinter, Reversing, Diamond, Covetous, or Blunt Arrow") + assert.is_true(contains(ids, HashStats({ "each_arrow_fired_gains_random_perdandus_prefix" }))) + end) + + it("returns no results for an unmatchable line", function() + local ids = tradeHelpers.findTradeHash("+100 to IQ") + assert.equal(0, #ids) + end) + + it("works thrice in a row", function() + local a = tradeHelpers.findTradeHash("+50 to maximum Life") + local b = tradeHelpers.findTradeHash("+50 to maximum Life") + local c = tradeHelpers.findTradeHash("+50 to maximum Life") + assert.same(a, b) + assert.same(b, c) + end) + + it("detects inverted mods correctly", function() + -- note that this stat is a handwrap mod and doesn't actually exist on the trade site + local ids, value, shouldNegate = tradeHelpers.findTradeHash("100% more damage taken while on low life") + assert.equal(100, value) + assert.is_true(shouldNegate) + assert.equal(1, #ids) + + local ids, value, shouldNegate = tradeHelpers.findTradeHash("67% reduced maximum life") + assert.equal(67, value) + assert.is_true(shouldNegate) + assert.equal(1, #ids) + end) + it("detects mods with lua pattern characters correctly", function() + -- there is a form of this line which is literally 3.5% without a variable + local ids, value = tradeHelpers.findTradeHash("Socketed Gems have +3.5% Critical Hit Chance") + assert.is_true(contains(ids, HashStats({ "local_display_socketed_gems_additional_critical_strike_chance_%" }))) + -- for some reason the range is 3-3 on the descriptor. this behaviour is still correct + assert.equal(3, value) + + local ids, value, shouldNegate = tradeHelpers.findTradeHash( + "10% reduced effect of Non-Curse Auras from your Skills on your Minions") + assert.is_true(contains(ids, HashStats({ "minions_have_non_curse_aura_effect_+%_from_parent_skills" }))) + assert.equal(10, value) + assert.is_true(shouldNegate) + end) + + it("matches time-lost jewel mods correctly", function() + local ids, value = tradeHelpers.findTradeHash( + "Small Passive Skills in Radius also grant 3% increased Damage with Bows") + assert.equal(1, #ids) + assert.is_true(contains(ids, HashStats({ "bow_damage_+%", "local_jewel_mod_stats_added_to_small_passives" }))) + assert.equal(3, value) + + local ids, value = tradeHelpers.findTradeHash( + "Notable Passive Skills in Radius also grant 7% increased Critical Hit Chance for Attacks") + assert.is_true(contains(ids, + HashStats({ "attack_critical_strike_chance_+%", "local_jewel_mod_stats_added_to_notable_passives" }))) + assert.equal(7, value) + end) + end) +end) diff --git a/src/Classes/CompareBuySimilar.lua.rej b/src/Classes/CompareBuySimilar.lua.rej new file mode 100644 index 0000000000..73404535b9 --- /dev/null +++ b/src/Classes/CompareBuySimilar.lua.rej @@ -0,0 +1,218 @@ +diff a/src/Classes/CompareBuySimilar.lua b/src/Classes/CompareBuySimilar.lua (rejected hunks) +@@ -7,6 +7,15 @@ local t_insert = table.insert + local m_floor = math.floor + local dkjson = require "dkjson" + local tradeHelpers = LoadModule("Classes/TradeHelpers") ++local tradeStats = tradeHelpers.getTradeStats() ++ ++-- used to check what stats actually exist on the trade site. ++local existingStats = {} ++for _, cat in ipairs(tradeStats or {}) do ++ for _, entry in ipairs(cat.entries) do ++ existingStats[entry.id] = true ++ end ++end + + local M = {} + +@@ -128,13 +137,12 @@ local function buildURL(item, slotName, controls, modEntries, defenceEntries, is + -- Mod filters + for i, entry in ipairs(modEntries) do + local prefix = "mod" .. i +- if entry.tradeId and controls[prefix .. "Check"] and controls[prefix .. "Check"].state then +- local filter = { id = entry.tradeId } ++ local function getFilter(tradeId) ++ local filter = { id = tradeId } + if entry.isOption then + filter.value = { min = entry.value, max = entry.value } + elseif entry.value then + local minVal = tonumber(controls[prefix .. "Min"].buf) +- + local maxVal = tonumber(controls[prefix .. "Max"].buf) + local value = {} + if minVal then +@@ -152,7 +160,20 @@ local function buildURL(item, slotName, controls, modEntries, defenceEntries, is + filter.value = value + end + end +- t_insert(queryTable.query.stats[1].filters, filter) ++ return filter ++ end ++ if controls[prefix .. "Check"] and controls[prefix .. "Check"].state then ++ if #entry.tradeIds == 1 then ++ -- 1 id entries are added to the stat filters section ++ t_insert(queryTable.query.stats[1].filters, getFilter(entry.tradeIds[1])) ++ elseif #entry.tradeIds > 1 then ++ -- ambiguous entries are added as a separate count filter ++ local countFilter = { type = "count", value = { min = 1 }, filters = {} } ++ for _, tradeId in ipairs(entry.tradeIds) do ++ t_insert(countFilter.filters, getFilter(tradeId)) ++ end ++ t_insert(queryTable.query.stats, countFilter) ++ end + end + end + +@@ -176,42 +197,28 @@ local function buildURL(item, slotName, controls, modEntries, defenceEntries, is + return url + end + +--- Open the Buy Similar popup for a compared item +-function M.openPopup(item, slotName, primaryBuild) +- if not item then return end +- +- local isUnique = item.rarity == "UNIQUE" or item.rarity == "RELIC" +- local controls = {} +- local uri = "" +- local rowHeight = 24 +- local popupWidth = 700 +- local leftMargin = 20 +- local minFieldX = popupWidth - 130 +- local maxFieldX = popupWidth - 50 +- local fieldW = 60 +- local fieldH = 20 +- local checkboxSize = 20 +- +- -- Collect mod entries with trade IDs ++---@param item any ++---@param modTypeSources ModTypeSources ++---@return table[] entries mod entries used in buy similar popup ++function M.addModEntries(item, modTypeSources) + local modEntries = {} +- local modTypeSources = { +- { list = item.enchantModLines, type = "enchant" }, +- { list = item.implicitModLines, type = "implicit" }, +- { list = item.explicitModLines, type = "explicit" }, +- } +- -- this adds a single aggregated entry for matching stats (e.g. transformed flat dmg mods) which avoids issues with confusing results. different types are not summed as e.g. implicit and explicit mods are separate in the search. options are also avoided as they don't represent values that can be added combined ++ -- this adds a single aggregated entry for matching stats (e.g. transformed flat dmg mods) which ++ -- avoids issues with confusing results. mods with different types are not summed as e.g. ++ -- implicit and explicit mods are separate in the search. options are also avoided as they don't ++ -- represent values that can be added combined + local function insertOrAddToExisting(entry) + for _, existingFilter in ipairs(modEntries) do +- if (not existingFilter.isOption) and entry.value +- and existingFilter.tradeId and existingFilter.tradeId == entry.tradeId +- and existingFilter.type == entry.type +- then +- existingFilter.count = existingFilter.count + 1 +- local value = (entry.invert ~= existingFilter.invert) and -entry.value or entry.value +- existingFilter.value = (existingFilter.value or 0) + value ++ -- check if all result trade ids are equal ++ local sameHashes = #entry.tradeIds > 0 and tableDeepEquals(entry.tradeIds, existingFilter.tradeIds) ++ if sameHashes and existingFilter.type == entry.type then ++ if entry.value then ++ local value = (entry.invert ~= existingFilter.invert) and -entry.value or entry.value or 0 ++ existingFilter.value = (existingFilter.value or 0) + value ++ end + t_insert(existingFilter.formattedLines, entry.formattedLines[1]) + return + end ++ ::continue:: + end + t_insert(modEntries, entry) + end +@@ -228,29 +235,70 @@ function M.openPopup(item, slotName, primaryBuild) + -- Use range-resolved text for matching + local resolvedLine = (modLine.range and itemLib.applyRange(modLine.line, modLine.range, modLine.valueScalar)) or + modLine.line +- local tradeHash, identifier, value = tradeHelpers.findTradeHash(item, resolvedLine, source.type, modLine.desecrated) +- local isOption = not not identifier +- if not identifier then +- identifier = tradeHash and string.format("%s.stat_%s", source.type, tradeHash) +- value = tradeHelpers.modLineValue(resolvedLine) +- end +- local invert = (not isOption) and tradeHelpers.shouldBeInverted(identifier, resolvedLine, source.type) +- insertOrAddToExisting({ ++ -- check option first, because even if we match a line via the descriptors, the trade id formatting is different for options. e.g.: explicit.stat_345345|33 ++ local tradeId, value = tradeHelpers.findTradeIdOption(resolvedLine, source.type) ++ ++ local entry = { + -- this array will always start with one line, but if multiple mods are + -- aggregated together it will contain the original mod lines for each +- formattedLines = {formatted}, +- tradeId = identifier, +- value = value, +- isOption = isOption, ++ formattedLines = { formatted }, + type = source.type, +- invert = invert, +- count = 1, +- }) ++ isOption = not not tradeId, ++ invert = false, ++ tradeIds = { tradeId }, ++ value = value, ++ } ++ if not tradeId then ++ local resultHashes, value, invert = tradeHelpers.findTradeHash(resolvedLine) ++ -- convert hashes to string ids ++ local resultIds = {} ++ if resultHashes then ++ for idx = 1, #resultHashes do ++ local id = string.format("%s.stat_%s", source.type, resultHashes[idx]) ++ if existingStats[id] then ++ t_insert(resultIds, id) ++ end ++ end ++ end ++ entry.tradeIds = resultIds ++ entry.value = value ++ entry.invert = invert ++ end ++ insertOrAddToExisting(entry) + end + end + end + end + end ++ return modEntries ++end ++ ++-- Open the Buy Similar popup for a compared item ++function M.openPopup(item, slotName, primaryBuild) ++ if not item then return end ++ ++ local isUnique = item.rarity == "UNIQUE" or item.rarity == "RELIC" ++ local controls = {} ++ local uri = "" ++ local rowHeight = 24 ++ local popupWidth = 700 ++ local leftMargin = 20 ++ local minFieldX = popupWidth - 130 ++ local maxFieldX = popupWidth - 50 ++ local fieldW = 74 ++ local fieldH = 20 ++ local checkboxSize = 20 ++ ++ ++ ---@class ModTypeSources ++ local modTypeSources = { ++ { list = item.enchantModLines, type = "enchant" }, ++ { list = item.implicitModLines, type = "implicit" }, ++ { list = item.explicitModLines, type = "explicit" }, ++ } ++ ++ -- Collect mod entries with trade IDs ++ local modEntries = M.addModEntries(item, modTypeSources) + + -- Collect defence stats for non-unique gear items + local defenceEntries = {} +@@ -395,7 +443,7 @@ function M.openPopup(item, slotName, primaryBuild) + end + prevType = entry.type + local prefix = "mod" .. i +- local canSearch = entry.tradeId ~= nil ++ local canSearch = #entry.tradeIds > 0 + + local rows = #entry.formattedLines + +@@ -411,6 +459,8 @@ function M.openPopup(item, slotName, primaryBuild) + --- @type string[] + local displayTexts = entry.formattedLines + for index, displayText in ipairs(displayTexts) do ++ -- shorten time-lost jewel affix labels to fit better ++ displayText = displayText:gsub(" Passive Skills in Radius also grant", ":") + local colorCodeLength = displayText:match("(%^x%x%x%x%x%x%x)") or displayText:gsub("(%^%x)", "") or "" + + if not canSearch then diff --git a/src/Data/QueryMods.lua.rej b/src/Data/QueryMods.lua.rej new file mode 100644 index 0000000000..da490c71ab --- /dev/null +++ b/src/Data/QueryMods.lua.rej @@ -0,0 +1,75 @@ +diff a/src/Data/QueryMods.lua b/src/Data/QueryMods.lua (rejected hunks) +@@ -27784,20 +27784,6 @@ return { + ["type"] = "implicit", + }, + }, +- ["3489782002"] = { +- ["Amulet"] = { +- ["max"] = 30, +- ["min"] = 20, +- }, +- ["specialCaseData"] = { +- }, +- ["tradeMod"] = { +- ["id"] = "implicit.stat_3489782002", +- ["text"] = "# to maximum Energy Shield", +- ["type"] = "implicit", +- }, +- ["usePositiveSign"] = true, +- }, + ["3544800472"] = { + ["Chest"] = { + ["max"] = 40, +@@ -28544,14 +28530,6 @@ return { + ["max"] = 1, + ["min"] = 1, + }, +- ["2HWeapon"] = { +- ["max"] = 1, +- ["min"] = 1, +- }, +- ["Quarterstaff"] = { +- ["max"] = 1, +- ["min"] = 1, +- }, + ["specialCaseData"] = { + }, + ["tradeMod"] = { +@@ -28726,18 +28704,10 @@ return { + ["max"] = -10, + ["min"] = -10, + }, +- ["2HWeapon"] = { +- ["max"] = -10, +- ["min"] = -10, +- }, + ["Helmet"] = { + ["max"] = 4, + ["min"] = 4, + }, +- ["Quarterstaff"] = { +- ["max"] = -10, +- ["min"] = -10, +- }, + ["Spear"] = { + ["max"] = -10, + ["min"] = -10, +@@ -31410,18 +31380,10 @@ return { + ["max"] = 4, + ["min"] = 4, + }, +- ["2HWeapon"] = { +- ["max"] = 4, +- ["min"] = 4, +- }, + ["Helmet"] = { + ["max"] = 1, + ["min"] = 1, + }, +- ["Quarterstaff"] = { +- ["max"] = 4, +- ["min"] = 4, +- }, + ["Spear"] = { + ["max"] = 4, + ["min"] = 4, diff --git a/src/Export/Scripts/mods.lua.rej b/src/Export/Scripts/mods.lua.rej new file mode 100644 index 0000000000..6303d116fc --- /dev/null +++ b/src/Export/Scripts/mods.lua.rej @@ -0,0 +1,50 @@ +diff a/src/Export/Scripts/mods.lua b/src/Export/Scripts/mods.lua (rejected hunks) +@@ -32,21 +32,6 @@ function table.containsId(table, element) + return false + end + +--- used for calculating the hash field of a stat +-local GGG_STAT_HASH32_SEED = 0xC58F1A7B +--- used for calculating the trade hash from stat hash fields +-local GGG_TRADE_SEED = 0x02312233 +----@param stats string[] +----@return integer +-local function hashStats(stats) +- local statHashes = "" +- for _, statName in ipairs(stats) do +- local newHash = intToBytes(murmurHash2(statName, GGG_STAT_HASH32_SEED)) +- statHashes = statHashes..newHash +- end +- return murmurHash2(statHashes, GGG_TRADE_SEED) +-end +- + local whiteListStat = { + ["dummy_stat_display_nothing"] = true, + } +@@ -197,14 +182,15 @@ local function writeMods(outName, condFunc) + local stats = copyTable(statEntry.stats) + -- radius jewels lack a proper stat descriptor and so we add it manually + local prefix ++ local extraStat + -- radius jewel mods: + -- notable + if mod.NodeType == 2 then +- table.insert(stats, "local_jewel_mod_stats_added_to_notable_passives") ++ extraStat = "local_jewel_mod_stats_added_to_notable_passives" + prefix = "Notable Passive Skills in Radius also grant " + -- small + elseif mod.NodeType and mod.NodeType == 1 then +- table.insert(stats, "local_jewel_mod_stats_added_to_small_passives") ++ extraStat = "local_jewel_mod_stats_added_to_small_passives" + prefix = "Small Passive Skills in Radius also grant " + end + +@@ -217,7 +203,7 @@ local function writeMods(outName, condFunc) + end + end + +- local tradeHash = hashStats(stats) ++ local tradeHash = HashStats(stats, extraStat) + tradeHashes[tradeHash] = description + ::innerContinue:: + end diff --git a/src/Modules/Common.lua b/src/Modules/Common.lua index ae49f47782..3a8f03e004 100644 --- a/src/Modules/Common.lua +++ b/src/Modules/Common.lua @@ -984,3 +984,22 @@ function GetVirtualScreenSize() end return width, height end +-- used for calculating the hash field of a stat +local GGG_STAT_HASH32_SEED = 0xC58F1A7B +-- used for calculating the trade hash from stat hash fields +local GGG_TRADE_SEED = 0x02312233 +---@param stats string[] +---@param extraStat string extra stat for time-lost jewels +---@return integer +function HashStats(stats, extraStat) + if extraStat then + stats = copyTable(stats) + table.insert(stats, extraStat) + end + local statHashes = "" + for _, statName in ipairs(stats) do + local newHash = intToBytes(murmurHash2(statName, GGG_STAT_HASH32_SEED)) + statHashes = statHashes .. newHash + end + return murmurHash2(statHashes, GGG_TRADE_SEED) +end diff --git a/src/Modules/Common.lua.rej b/src/Modules/Common.lua.rej new file mode 100644 index 0000000000..2ab7826fe1 --- /dev/null +++ b/src/Modules/Common.lua.rej @@ -0,0 +1,10 @@ +diff a/src/Modules/Common.lua b/src/Modules/Common.lua (rejected hunks) +@@ -893,7 +893,7 @@ end + ---@return string + function stringify(thing) + if type(thing) == 'string' then +- local s = thing:gsub("\n", "") ++ local s = thing:gsub("\n", " ") + return s + elseif type(thing) == 'number' then + return ""..thing;