diff --git a/spec/System/TestCompareBuySimilar_spec.lua b/spec/System/TestCompareBuySimilar_spec.lua index fc38217f6e..d2b8e0d413 100644 --- a/spec/System/TestCompareBuySimilar_spec.lua +++ b/spec/System/TestCompareBuySimilar_spec.lua @@ -123,6 +123,8 @@ Can have 3 additional Enchantment Modifiers]]) end) end) describe("popup URL controls", function() + local tradeHelpers = require "Classes.TradeHelpers" + local originalEncode, encodedQuery local originalCopy local originalOpenURL local originalFetchLeagues @@ -135,6 +137,12 @@ Can have 3 additional Enchantment Modifiers]]) before_each(function() newBuild() + originalEncode = tradeHelpers.B64GzipEncode + -- SimpleGraphic provides compression; capture the query at that boundary. + tradeHelpers.B64GzipEncode = function(json) + encodedQuery = dkjson.decode(json) + return urlEncode(json) + end originalCopy = _G.Copy originalOpenURL = _G.OpenURL copiedUrl = nil @@ -146,6 +154,7 @@ Can have 3 additional Enchantment Modifiers]]) end) after_each(function() + tradeHelpers.B64GzipEncode = originalEncode build.itemsTab.tradeQuery.tradeQueryRequests.FetchLeagues = originalFetchLeagues _G.Copy = originalCopy _G.OpenURL = originalOpenURL @@ -166,8 +175,7 @@ Can have 3 additional Enchantment Modifiers]]) local function getQuery(controls) controls.search.onClick() - local queryJson = urlDecode(assert(copiedUrl:match("[?&]q=(.+)$"))) - return dkjson.decode(queryJson) + return encodedQuery end local function getUniqueQuery(name, baseName) @@ -185,13 +193,13 @@ Can have 3 additional Enchantment Modifiers]]) controls.leagueDrop:SetSel(isValueInArray(controls.leagueDrop.list, "Standard")) getQuery(controls) assert.not_equal(initialUrl, copiedUrl) - assert.is_truthy(copiedUrl:find("/Standard?", 1, true)) + assert.is_truthy(copiedUrl:find("/Standard/", 1, true)) local standardUrl = copiedUrl controls.listedDrop:SetSel(4) local query = getQuery(controls) assert.not_equal(standardUrl, copiedUrl) - assert.equal("any", query.query.status.option) + assert.equal("any", query.status.option) end) it("preserves apostrophes in unique names", function() @@ -202,15 +210,15 @@ Riveted Boots Implicits: 0]]) local controls = openPopup(item, "Boots") - assert.equal("Ralakesh's Impatience", getQuery(controls).query.name) + assert.equal("Ralakesh's Impatience", getQuery(controls).name) end) it("removes a trailing unique ID without removing name punctuation", function() - assert.equal("Uul-Netol's Embrace", getUniqueQuery("Uul-Netol's Embrace 1234", "Vaal Axe").query.name) + assert.equal("Uul-Netol's Embrace", getUniqueQuery("Uul-Netol's Embrace 1234", "Vaal Axe").name) end) it("trims whitespace after a unique name", function() - assert.equal("The Hateful Accuser", getUniqueQuery("The Hateful Accuser ", "Ghastly Eye Jewel").query.name) + assert.equal("The Hateful Accuser", getUniqueQuery("The Hateful Accuser ", "Ghastly Eye Jewel").name) end) it("persists league choices by name for each realm", function() @@ -250,7 +258,7 @@ Corrupted]]) controls.mod1Check.state = true controls.mod1Check.changeFunc() - local filter = getQuery(controls).query.stats[1].filters[1] + local filter = getQuery(controls).stats[1].filters[1] assert.equal("explicit.stat_2422708892", filter.id) assert.equal(22088, filter.value.option) end) @@ -266,7 +274,7 @@ Implicits: 0 controls.mod1Check.state = true controls.mod1Check.changeFunc() - local value = getQuery(controls).query.stats[1].filters[1].value + local value = getQuery(controls).stats[1].filters[1].value assert.is_nil(value.min) assert.equal(-67, value.max) end) @@ -293,7 +301,7 @@ Adds 5 to 15 Fire Damage]]) check.state = true check.changeFunc() - local countFilter = getQuery(controls).query.stats[2] + local countFilter = getQuery(controls).stats[2] assert.equal("count", countFilter.type) assert.equal(1, countFilter.value.min) assert.equal(#modEntries[ambiguousIndex].tradeIds, #countFilter.filters) diff --git a/spec/System/TestTimelessJewelSettings_spec.lua b/spec/System/TestTimelessJewelSettings_spec.lua index 50858ed40b..9a2719cf61 100644 --- a/spec/System/TestTimelessJewelSettings_spec.lua +++ b/spec/System/TestTimelessJewelSettings_spec.lua @@ -3,6 +3,55 @@ describe("TestTimelessJewelSettings", function() newBuild() end) + describe("trade search", function() + local tradeHelpers = require "Classes.TradeHelpers" + local originalEncode, originalCopy, originalOpenURL + local query, copiedUrl, openedUrl + + before_each(function() + originalEncode, originalCopy, originalOpenURL = tradeHelpers.B64GzipEncode, _G.Copy, _G.OpenURL + tradeHelpers.B64GzipEncode = function(json) + query = require("dkjson").decode(json) + return "encoded" + end + _G.Copy = function(url) copiedUrl = url end + _G.OpenURL = function(url) openedUrl = url end + end) + + after_each(function() + tradeHelpers.B64GzipEncode, _G.Copy, _G.OpenURL = originalEncode, originalCopy, originalOpenURL + main:ClosePopup() + end) + + it("includes seed and devotion constraints in a console trade link", function() + build.timelessData.jewelType = { id = 4 } + build.treeTab.tradeLeaguesList = { pc = { "Standard" }, xbox = { "Hardcore Test" } } + build.treeTab:FindTimelessJewel() + local controls = main.popups[1].controls + build.timelessData.sharedResults = { + conqueror = { id = 3 }, devotionVariant1 = { id = 2 }, devotionVariant2 = { id = 7 }, + } + build.timelessData.searchResults = { { seed = 5000 }, { seed = 6000 } } + controls.realmSelection:SetSel(3) + controls.tradeTypeSelection:SetSel(2) + controls.searchTradeButton.onClick() + + assert.are.equal("https://www.pathofexile.com/trade/search/xbox/Hardcore%20Test/encoded", openedUrl) + assert.are.equal(openedUrl, copiedUrl) + assert.are.equal("available", query.status.option) + assert.same({ + type = "count", value = { min = 1 }, filters = { + { id = data.timelessJewelTradeIDs[4].keystone[2], value = { min = 5000, max = 5000 } }, + { id = data.timelessJewelTradeIDs[4].keystone[2], value = { min = 6000, max = 6000 } }, + }, + }, query.stats[1]) + assert.same({ type = "and", filters = { + { id = data.timelessJewelTradeIDs[4].devotion[1] }, + { id = data.timelessJewelTradeIDs[4].devotion[6] }, + } }, query.stats[2]) + end) + end) + -- The finder's "Socket Jewel" toggle lives in timelessData, which only reaches the -- build XML through the TimelessData element, so cover the save/load round trip. it("round trips socketAllocate through the build XML", function() diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index ff8b269fc0..4f3c035b5e 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -193,8 +193,8 @@ describe("TradeQueryGenerator", function() } queryGen.tradeTypeIndex = 1 local query - queryGen.requesterCallback = function(_, queryJson) - query = require("dkjson").decode(queryJson).query + queryGen.requesterCallback = function(_, generatedQuery) + query = generatedQuery end queryGen:FinishQuery() @@ -202,6 +202,17 @@ describe("TradeQueryGenerator", function() assert.are.equal(31, #query.stats[1].filters) assert.is_not_nil(query.filters.socket_filters.filters.sockets) assert.is_not_nil(query.filters.socket_filters.filters.links) + + -- Account searches must also work without a maximum price. + queryGen.calcContext.options = { + statWeights = {}, influence1 = 1, influence2 = 1, + includeMirrored = true, account = "ExampleAccount", maxLevel = 80, links = 5, + } + queryGen:FinishQuery() + assert.same({ input = "ExampleAccount" }, query.filters.trade_filters.filters.account) + assert.same({ max = 80 }, query.filters.req_filters.filters.lvl) + assert.same({ min = 5, max = 5 }, query.filters.socket_filters.filters.links) + assert.is_nil(query.filters.socket_filters.filters.sockets) end) end) end) diff --git a/spec/System/TestTradeQueryRequests_spec.lua b/spec/System/TestTradeQueryRequests_spec.lua index f05068d8fa..ccb4fc6c51 100644 --- a/spec/System/TestTradeQueryRequests_spec.lua +++ b/spec/System/TestTradeQueryRequests_spec.lua @@ -283,6 +283,170 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] end) end) + describe("SearchWithURL", function() + local dkjson = require "dkjson" + local tradeHelpers = require "Classes.TradeHelpers" + local originalDecode, urlRequests, decodedQuery + local url = "https://www.pathofexile.com/trade/search/Standard/encoded" + + before_each(function() + urlRequests = new("TradeQueryRequests"):TradeQueryRequests(mock_limiter) + originalDecode = tradeHelpers.B64GzipDecode + -- Compression is provided by SimpleGraphic, which the headless wrapper stubs. + tradeHelpers.B64GzipDecode = function() return decodedQuery end + end) + + after_each(function() + tradeHelpers.B64GzipDecode = originalDecode + end) + + for _, realmPath in ipairs({ "", "sony/", "xbox/" }) do + it("preserves the realm and league in " .. realmPath .. "Hardcore%20Test", function() + tradeHelpers.B64GzipDecode = function(encoded) + assert.are.equal("encoded", encoded) + return [[{"stats":[{"type":"and","filters":[]}]}]] + end + urlRequests:SearchWithURL("https://pathofexile.com/trade/search/" .. realmPath .. "Hardcore%20Test/encoded", function() end) + assert.are.equal("https://www.pathofexile.com/api/trade/search/" .. realmPath .. "Hardcore%20Test", + urlRequests.requestQueue.search[1].url) + end) + end + + it("fetches ordinary searches by price and returns their query", function() + decodedQuery = [[{"stats":[{"type":"and","filters":[{"id":"explicit.stat_3299347043","value":{"min":1}}]}]}]] + local expectedQuery = dkjson.decode(decodedQuery) + local fetchedItems = { { id = "item1" } } + urlRequests.FetchResults = function(_, ids, queryId, callback) + assert.same({ "item1" }, ids) + assert.are.equal("searchId", queryId) + callback(fetchedItems) + end + local calls = 0 + urlRequests:SearchWithURL(url, function(items, err, query) + calls = calls + 1 + assert.is_nil(err) + assert.same(fetchedItems, items) + assert.same({ query = expectedQuery, sort = { price = "asc" }, engine = "new" }, dkjson.decode(query)) + end) + local search = table.remove(urlRequests.requestQueue.search, 1) + assert.are.equal("https://www.pathofexile.com/api/trade/search/Standard", search.url) + search.callback([[{"total":1,"result":["item1"],"id":"searchId"}]]) + assert.are.equal(1, calls) + assert.are.equal(0, #urlRequests.requestQueue.search) + end) + + it("relays ordinary search errors to the caller", function() + decodedQuery = [[{"stats":[{"type":"and","filters":[]}]}]] + local calls = 0 + urlRequests:SearchWithURL(url, function(items, err, query) + calls = calls + 1 + assert.is_nil(items) + assert.are.equal("Response code: 403", err) + assert.is_table(dkjson.decode(query).query) + end) + table.remove(urlRequests.requestQueue.search, 1).callback(nil, "Response code: 403") + assert.are.equal(1, calls) + end) + + it("moves a later weighted group first while retaining the other filters", function() + decodedQuery = [[{"stats":[{"type":"count","value":{"min":1},"filters":[]},{"type":"weight","value":{"min":10},"filters":[]}]}]] + urlRequests:SearchWithURL(url, function() end) + local query = dkjson.decode(urlRequests.requestQueue.search[1].body) + assert.same({ ["statgroup.0"] = "desc" }, query.sort) + assert.are.equal("weight", query.query.stats[1].type) + assert.are.equal(10, query.query.stats[1].value.min) + assert.are.equal("count", query.query.stats[2].type) + assert.are.equal(1, query.query.stats[2].value.min) + end) + + for _, case in ipairs({ + { name = "an explicit minimum with only five matches", bounds = { min = 2000 }, weight = 2100, total = 5 }, + { name = "negative weights with a maximum-only bound", bounds = { max = -50 }, weight = -60, total = 10000 }, + { name = "negative weights without bounds", weight = -60, total = 10000 }, + { name = "both bounds with exactly the fetch limit", bounds = { min = 100, max = 200 }, weight = 150, total = 10 }, + }) do + it("fetches pasted searches once, preserving " .. case.name, function() + local filters = { { id = "explicit.stat_3299347043", value = { weight = case.weight < 0 and -1 or 1 } } } + local suppliedQuery = { stats = { { type = "weight", filters = filters, value = case.bounds } } } + decodedQuery = dkjson.encode(suppliedQuery) + local fetchedItems = { { id = "item1", weight = tostring(case.weight) } } + local fetchCalls, callbackCalls = 0, 0 + urlRequests.FetchResults = function(_, ids, queryId, callback) + fetchCalls = fetchCalls + 1 + assert.same({ "item1" }, ids) + assert.are.equal("searchId", queryId) + callback(fetchedItems) + end + urlRequests:SearchWithURL(url, function(items, err, query) + callbackCalls = callbackCalls + 1 + assert.is_nil(err) + assert.same(fetchedItems, items) + assert.same({ query = suppliedQuery, sort = { ["statgroup.0"] = "desc" }, engine = "new" }, dkjson.decode(query)) + end) + assert.are.equal(1, #urlRequests.requestQueue.search) + local search = table.remove(urlRequests.requestQueue.search, 1) + assert.same(suppliedQuery, dkjson.decode(search.body).query) + search.callback(dkjson.encode({ total = case.total, result = { "item1" }, id = "searchId" })) + assert.are.equal(1, fetchCalls) + assert.are.equal(1, callbackCalls) + assert.are.equal(0, #urlRequests.requestQueue.search) + end) + end + + it("reports no matches without relaxing a pasted minimum", function() + decodedQuery = [[{"stats":[{"type":"weight","value":{"min":2000},"filters":[]}]}]] + local calls = 0 + urlRequests:SearchWithURL(url, function(items, err, query) + calls = calls + 1 + assert.is_nil(items) + assert.are.equal("No Matching Results Found", err) + assert.are.equal(2000, dkjson.decode(query).query.stats[1].value.min) + end) + table.remove(urlRequests.requestQueue.search, 1).callback([[{"total":0,"result":[],"id":"emptySearch"}]]) + assert.are.equal(1, calls) + assert.are.equal(0, #urlRequests.requestQueue.search) + end) + + for _, malformed in ipairs({ + [[{"stats":[]}]], + [[{"stats":[{"type":"and"},false]}]], + [[{"stats":[{}]}]], + [[42]], + [[not JSON]], + }) do + it("rejects malformed query " .. malformed .. " through the callback", function() + decodedQuery = malformed + local calls = 0 + urlRequests:SearchWithURL(url, function(items, err) + calls = calls + 1 + assert.is_nil(items) + assert.are.equal("URL is malformed", err) + end) + assert.are.equal(1, calls) + assert.are.equal(0, #urlRequests.requestQueue.search) + end) + end + + it("rejects failed decompression through the callback", function() + decodedQuery = nil + local calls = 0 + urlRequests:SearchWithURL(url, function(_, err) + calls = calls + 1 + assert.are.equal("URL is malformed", err) + end) + assert.are.equal(1, calls) + end) + + it("rejects an unrelated URL through the callback", function() + local calls = 0 + urlRequests:SearchWithURL("https://example.com/", function(_, err) + calls = calls + 1 + assert.are.equal("Invalid URL", err) + end) + assert.are.equal(1, calls) + end) + end) + describe("FetchResults", function() -- Pass: Fetches exactly 10 from 11, in 1 block -- Fail: Fetches wrong count/blocks, indicating batch limit violation, triggering rate limits diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index c700a332cf..9546edac43 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -61,6 +61,90 @@ describe("TradeQuery", function() assert.are.equal(0, #tooltip.lines) end) end) + describe("trade search links", function() + local dkjson = require "dkjson" + local tradeHelpers = require "Classes.TradeHelpers" + local originalEncode, originalCopy, originalOpenURL, originalToken + local encodedQuery, copiedUrl, openedUrl + local generatedQuery = { stats = { { type = "weight", value = { min = 10 }, filters = {} } } } + + before_each(function() + originalEncode, originalCopy, originalOpenURL, originalToken = tradeHelpers.B64GzipEncode, _G.Copy, _G.OpenURL, main.api.authToken + tradeHelpers.B64GzipEncode = function(json) + encodedQuery = dkjson.decode(json) + return "encoded" + end + _G.Copy = function(url) copiedUrl = url end + _G.OpenURL = function(url) openedUrl = url end + encodedQuery, copiedUrl, openedUrl = nil, nil, nil + mock_tradeQuery.itemsTab.activeItemSet = {} + mock_tradeQuery.itemsTab.slots = {} + mock_tradeQuery.slotTables[1] = { slotName = "Ring 1" } + mock_tradeQuery.controls.pbNotice = {} + mock_tradeQuery:PriceItemRowDisplay(1, nil, 0, 20) + mock_tradeQuery.pbRealm = "sony" + mock_tradeQuery.pbLeague = "Hardcore Test" + mock_tradeQuery.tradeQueryGenerator = { + RequestQuery = function(_, slot, context, weights, callback) + callback(context, copyTable(generatedQuery, true)) + end, + } + end) + + after_each(function() + tradeHelpers.B64GzipEncode, _G.Copy, _G.OpenURL, main.api.authToken = originalEncode, originalCopy, originalOpenURL, originalToken + end) + + it("creates an unauthenticated search link from the generated query", function() + main.api.authToken = nil + mock_tradeQuery.controls.bestButton1.onClick() + assert.same(generatedQuery, encodedQuery) + assert.are.equal("https://www.pathofexile.com/trade/search/sony/Hardcore%20Test/encoded", mock_tradeQuery.controls.uri1.buf) + assert.is_true(mock_tradeQuery.controls.uri1.validURL) + assert.are.equal(0, #mock_tradeQuery.tradeQueryRequests.requestQueue.search) + end) + + it("posts generated queries with weighted sorting and keeps the adjusted query", function() + main.api.authToken = "test-token" + -- Complete two searches through the real request flow, without fetching items. + mock_tradeQuery.tradeQueryRequests.FetchResults = function(_, ids, queryId, callback) callback({}) end + mock_tradeQuery.UpdateControlsWithItems = function() end + mock_tradeQuery.controls.bestButton1.onClick() + local requests = mock_tradeQuery.tradeQueryRequests.requestQueue.search + local firstSearch = table.remove(requests, 1) + assert.are.equal("https://www.pathofexile.com/api/trade/search/sony/Hardcore%20Test", firstSearch.url) + assert.same({ query = generatedQuery, sort = { ["statgroup.0"] = "desc" }, engine = "new" }, dkjson.decode(firstSearch.body)) + firstSearch.callback([[{"id":"first","total":1,"result":["item1"]}]]) + local finalSearch = table.remove(requests, 1) + assert.are.equal(5, dkjson.decode(finalSearch.body).query.stats[1].value.min) + finalSearch.callback([[{"id":"final","total":10,"result":["item1"]}]]) + assert.are.equal(finalSearch.body, mock_tradeQuery.lastQueries[1]) + assert.are.equal("https://www.pathofexile.com/trade/search/sony/Hardcore%20Test/final", mock_tradeQuery.controls.uri1.buf) + assert.are.equal("Price Item", mock_tradeQuery.controls.priceButton1.label) + end) + + for _, group in ipairs({ + { type = "and", filters = {} }, + { type = "count", value = { min = 1 }, filters = {} }, + { type = "weight", value = { min = 10 }, filters = {} }, + }) do + it("preserves " .. group.type .. " group semantics in purchase links", function() + mock_tradeQuery.itemIndexTbl[1] = 1 + mock_tradeQuery.resultTbl[1] = { { trader = "ExampleAccount", weight = "100", priceType = "~b/o" } } + mock_tradeQuery.lastQueries[1] = dkjson.encode({ query = { stats = { group } } }) + mock_tradeQuery.controls.whisperButton1.onClick() + if group.type == "weight" then + assert.same({ min = 99, max = 101 }, encodedQuery.stats[1].value) + else + assert.same(group, encodedQuery.stats[1]) + end + assert.same({ input = "ExampleAccount" }, encodedQuery.filters.trade_filters.filters.account) + assert.are.equal("https://www.pathofexile.com/trade/search/sony/Hardcore%20Test/encoded", openedUrl) + assert.are.equal(openedUrl, copiedUrl) + end) + end + end) + describe("GetResultEvaluation", function() it("uses the first visible ring for a Pearl result without a selected slot", function() local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) diff --git a/src/Classes/CompareBuySimilar.lua b/src/Classes/CompareBuySimilar.lua index 55445eae65..8c3f5b1af9 100644 --- a/src/Classes/CompareBuySimilar.lua +++ b/src/Classes/CompareBuySimilar.lua @@ -60,16 +60,13 @@ local function buildURL(item, slotName, controls, modEntries, defenceEntries, is -- Build query local queryTable = { - query = { - status = { option = listedApiValue }, - stats = { - { - type = "and", - filters = {} - } - }, + status = { option = listedApiValue }, + stats = { + { + type = "and", + filters = {} + } }, - sort = { price = "asc" } } local queryFilters = {} @@ -81,8 +78,8 @@ local function buildURL(item, slotName, controls, modEntries, defenceEntries, is -- timeless jewel ids or other numbers appended to the item name. tradeName = tradeName:gsub("%s+$", "") tradeName = tradeName:match("^(.-)%s+%d+$") or tradeName - queryTable.query.name = tradeName - queryTable.query.type = item.baseName + queryTable.name = tradeName + queryTable.type = item.baseName -- If item is Foulborn, add the foulborn_item filter if item.foulborn then queryFilters.misc_filters = queryFilters.misc_filters or { filters = {} } @@ -101,7 +98,7 @@ local function buildURL(item, slotName, controls, modEntries, defenceEntries, is -- Base type filter if controls.baseTypeCheck and controls.baseTypeCheck.state then - queryTable.query.type = item.baseName + queryTable.type = item.baseName end -- Item level filter @@ -173,21 +170,21 @@ local function buildURL(item, slotName, controls, modEntries, defenceEntries, is 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])) + t_insert(queryTable.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) + t_insert(queryTable.stats, countFilter) end end end -- Only include filters if we have any if next(queryFilters) then - queryTable.query.filters = queryFilters + queryTable.filters = queryFilters end -- Build URL @@ -199,8 +196,8 @@ local function buildURL(item, slotName, controls, modEntries, defenceEntries, is local encodedLeague = league:gsub("[^%w%-%.%_%~]", function(c) return string.format("%%%02X", string.byte(c)) end):gsub(" ", "+") - url = url .. "/" .. encodedLeague - url = url .. "?q=" .. urlEncode(queryJson) + url ..= "/" .. encodedLeague + url ..= "/" .. tradeHelpers.B64GzipEncode(queryJson) return url end diff --git a/src/Classes/TradeHelpers.lua b/src/Classes/TradeHelpers.lua index 444834a797..9f5580ce70 100644 --- a/src/Classes/TradeHelpers.lua +++ b/src/Classes/TradeHelpers.lua @@ -620,4 +620,23 @@ function M.newPlainNumericEdit(anchor, rect, init, prompt, limit, integer, chang end return ctrl end + + +---@param str string String which will be encoded +---@return string? result The given string, gzipped and then Base64URL encoded +function M.B64GzipEncode(str) + local b64 = require("base64") + local deflated = Deflate(str, true) + if not deflated then return end + return b64.encode(deflated):gsub("%+", "-"):gsub("/", "_") +end + +---@param str string String which will be decoded +---@return string? result The given string, Base64URL decoded and the ungzipped +function M.B64GzipDecode(str) + local b64 = require("base64") + local data = b64.decode(str:gsub("%-", "+"):gsub("_", "/")) + if not data then return end + return Inflate(data) +end return M diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 66ad1fc1ff..1d0efbbeb8 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -7,6 +7,7 @@ local dkjson = require "dkjson" local itemSlotHelper = require("Modules.ItemSlotHelper") +local tradeHelpers = require("Classes.TradeHelpers") local get_time = os.time local t_insert = table.insert @@ -1052,7 +1053,8 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro local nameColor = slotTbl.unique and colorCodes.UNIQUE or "^7" controls["name" .. row_idx] = new("LabelControl"):LabelControl(top_pane_alignment_ref, { 0, row_idx * (row_height + row_vertical_padding), 135, row_height - 4 }, nameColor .. slotTbl.slotName) controls["bestButton" .. row_idx] = new("ButtonControl"):ButtonControl({ "LEFT", controls["name" .. row_idx], "LEFT" }, { 135 + 8, 0, 80, row_height }, "Find best", function() - self.tradeQueryGenerator:RequestQuery(activeSlot, { slotTbl = slotTbl, controls = controls, row_idx = row_idx }, self.statSortSelectionList, function(context, query, errMsg) + ---@param query table A table of filters + local function requestQueryHandler(context, query, errMsg) if errMsg then self:SetNotice(context.controls.pbNotice, colorCodes.NEGATIVE .. errMsg) return @@ -1061,14 +1063,19 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro end if main.api.authToken == nil then local url = self.tradeQueryRequests:buildUrl(self.hostName .. "trade/search", self.pbRealm, self.pbLeague) - url = url .. "?q=" .. urlEncode(query) + url = url .. "/" .. tradeHelpers.B64GzipEncode(dkjson.encode(query)) controls["uri"..context.row_idx]:SetText(url, true) return end context.controls["priceButton"..context.row_idx].label = "Searching..." - self.lastQueries[row_idx] = query + -- Trade URLs contain only the query; the API also needs the sort order. + query = dkjson.encode({ + query = query, + sort = { ["statgroup.0"] = "desc" }, + engine = "new", + }) self.tradeQueryRequests:SearchWithQueryWeightAdjusted(self.pbRealm, self.pbLeague, query, - function(items, errMsg) + function(items, errMsg, finalQuery) if errMsg then self:SetNotice(context.controls.pbNotice, colorCodes.NEGATIVE .. errMsg) context.controls["priceButton"..context.row_idx].label = "Price Item" @@ -1077,6 +1084,7 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro self:SetNotice(context.controls.pbNotice, "") end + self.lastQueries[row_idx] = finalQuery local selectedSlot = getSelectedSlot() local itemsSafe = self:FilterToSafeItems(items, selectedSlot and selectedSlot.slotName) -- replace eldritch mods or enchants if the user requested @@ -1109,7 +1117,8 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro end } ) - end) + end + self.tradeQueryGenerator:RequestQuery(activeSlot, { slotTbl = slotTbl, controls = controls, row_idx = row_idx }, self.statSortSelectionList, requestQueryHandler) end) controls["bestButton"..row_idx].shown = function() return not self.resultTbl[row_idx] end controls["bestButton"..row_idx].enabled = function() return self.pbLeague end @@ -1300,7 +1309,9 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite -- use trade sum to get the specific item. both min and max -- weight on site uses floats but only shows integer in the api -- e.g. weight of 172.3 shows up as 172 in the api - exactQuery.query.stats[1].value = { min = floor(itemResult.weight, 1) - 1, max = round(itemResult.weight, 1) + 1 } + if exactQuery.query.stats[1].type == "weight" then + exactQuery.query.stats[1].value = { min = floor(itemResult.weight, 1) - 1, max = round(itemResult.weight, 1) + 1 } + end -- also apply trader name. this should make false positives -- extremely unlikely. this doesn't seem to take up a filter slot exactQuery.query.filters = exactQuery.query.filters or { } @@ -1308,9 +1319,8 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite exactQuery.query.filters.trade_filters.filters = exactQuery.query.filters.trade_filters.filters or { } exactQuery.query.filters.trade_filters.filters.account = { input = itemResult.trader } - local exactQueryStr = dkjson.encode(exactQuery) - - local encodedUrl = s_format("https://www.pathofexile.com/trade/search/%s?q=%s", self.pbLeague, urlEncode(exactQueryStr)) + local encodedUrl = self.tradeQueryRequests:buildUrl(self.hostName .. "trade/search", self.pbRealm, self.pbLeague, + tradeHelpers.B64GzipEncode(dkjson.encode(exactQuery.query))) Copy(encodedUrl) OpenURL(encodedUrl) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 826cb34a08..097a2e7f94 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -961,38 +961,34 @@ function TradeQueryGeneratorClass:FinishQuery() local requiredMods = self.calcContext.requiredMods or {} local blockedMods = self.calcContext.blockedMods or {} local queryTable = { - query = { - filters = self.calcContext.special.queryFilters or { - type_filters = { - filters = { - category = { option = self.calcContext.itemCategoryQueryStr }, - rarity = { option = "nonunique" } - } + filters = self.calcContext.special.queryFilters or { + type_filters = { + filters = { + category = { option = self.calcContext.itemCategoryQueryStr }, + rarity = { option = "nonunique" } } + } + }, + status = { option = selectedTradeType }, + stats = { + { + type = "weight", + value = { min = minWeight }, + filters = {}, }, - status = { option = selectedTradeType }, - stats = { - { - type = "weight", - value = { min = minWeight }, - filters = {}, - }, - { - type = "and", - filters = {}, - }, - { - type = "not", - filters = {}, - } + { + type = "and", + filters = {}, + }, + { + type = "not", + filters = {}, } }, - sort = { ["statgroup.0"] = "desc" }, - engine = "new" } - local weightGroup = queryTable.query.stats[1] - local andGroup = queryTable.query.stats[2] - local notGroup = queryTable.query.stats[3] + local weightGroup = queryTable.stats[1] + local andGroup = queryTable.stats[2] + local notGroup = queryTable.stats[3] -- the trade site has a maximum complexity of 200 for each query. our baseline is 54 for the weighted sum group, 4 for the rarity filter plus category, and 4 for the and group local complexityBudget = 200 - 54 - 4 - 4 @@ -1058,7 +1054,7 @@ function TradeQueryGeneratorClass:FinishQuery() for k, v in pairs(self.calcContext.special.queryExtra or {}) do complexityBudget = complexityBudget - 2 - queryTable.query[k] = v + queryTable[k] = v end -- and filters specified by the user @@ -1073,7 +1069,7 @@ function TradeQueryGeneratorClass:FinishQuery() local options = self.calcContext.options if not options.includeMirrored then complexityBudget = complexityBudget - 3 - queryTable.query.filters.misc_filters = { + queryTable.filters.misc_filters = { disabled = false, filters = { mirrored = false, @@ -1083,7 +1079,7 @@ function TradeQueryGeneratorClass:FinishQuery() if options.maxPrice and options.maxPrice > 0 then complexityBudget = complexityBudget - 3 - queryTable.query.filters.trade_filters = { + queryTable.filters.trade_filters = { filters = { price = { option = options.maxPriceType, @@ -1095,12 +1091,13 @@ function TradeQueryGeneratorClass:FinishQuery() if options.account then complexityBudget = complexityBudget - 3 - queryTable.query.filters.trade_filters.filters.account = { input = options.account } + queryTable.filters.trade_filters = queryTable.filters.trade_filters or { filters = {} } + queryTable.filters.trade_filters.filters.account = { input = options.account } end if options.maxLevel and options.maxLevel > 0 then complexityBudget = complexityBudget - 3 - queryTable.query.filters.req_filters = { + queryTable.filters.req_filters = { disabled = false, filters = { lvl = { @@ -1112,7 +1109,7 @@ function TradeQueryGeneratorClass:FinishQuery() if options.sockets and options.sockets > 0 then complexityBudget = complexityBudget - 3 - queryTable.query.filters.socket_filters = { + queryTable.filters.socket_filters = { disabled = false, filters = { sockets = { @@ -1125,8 +1122,8 @@ function TradeQueryGeneratorClass:FinishQuery() if options.links and options.links > 0 then complexityBudget = complexityBudget - 3 - if not queryTable.query.filters.socket_filters then - queryTable.query.filters.socket_filters = { + if not queryTable.filters.socket_filters then + queryTable.filters.socket_filters = { disabled = false, filters = { links = { @@ -1136,7 +1133,7 @@ function TradeQueryGeneratorClass:FinishQuery() } } else -- do not overwrite options.sockets - queryTable.query.filters.socket_filters.filters["links"] = { + queryTable.filters.socket_filters.filters["links"] = { max = options.links, min = options.links } @@ -1158,8 +1155,7 @@ function TradeQueryGeneratorClass:FinishQuery() errMsg = "Could not generate search, found no mods to search for" end - local queryJson = dkjson.encode(queryTable) - self.requesterCallback(self.requesterContext, queryJson, errMsg) + self.requesterCallback(self.requesterContext, queryTable, errMsg) -- Close blocker popup main:ClosePopup() diff --git a/src/Classes/TradeQueryRequests.lua b/src/Classes/TradeQueryRequests.lua index 0ddff307eb..0c93599196 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -6,6 +6,7 @@ local dkjson = require "dkjson" local utils = require("Modules.Utils") +local tradeHelpers = require("Classes.TradeHelpers") local tradeInfluenceApiKeys = { shaper = "shaper", @@ -114,7 +115,7 @@ end ---the search to fetch more items when the search cap (10k items) is reached ---@param league string ---@param query string ----@param callback fun(items:table, errMsg:string) +---@param callback fun(items: table, errMsg: string, query: string) ---@param params table @ params = { callbackQueryId = fun(queryId:string) } function TradeQueryRequestsClass:SearchWithQueryWeightAdjusted(realm, league, query, callback, params) params = params or {} @@ -125,13 +126,17 @@ function TradeQueryRequestsClass:SearchWithQueryWeightAdjusted(realm, league, qu -- Each repeat is a leap of 10k items, normally we shouldn't need more than 1-2 steps anyways local maxRecursion = 5 local currentRecursion = 0 + -- the query is adjusted as the search repeats, so return the final query + local function resultCallback(items, errMsg) + return callback(items, errMsg, query) + end local function performSearchCallback(response, errMsg) currentRecursion = currentRecursion + 1 if params.callbackQueryId and response and response.id then params.callbackQueryId(response.id) end if errMsg and ((errMsg == "No Matching Results Found" and currentRecursion >= maxRecursion) or errMsg ~= "No Matching Results Found") then - return callback(nil, errMsg) + return resultCallback(nil, errMsg) end if (response.total >= self.maxFetchPerSearch and response.total < 10000) or currentRecursion >= maxRecursion then -- Search not clipped or max recursion reached, fetch results and finalize @@ -139,7 +144,7 @@ function TradeQueryRequestsClass:SearchWithQueryWeightAdjusted(realm, league, qu -- Not enough items in the last search, fill results from previous search self:FetchResults(response.result, response.id, function(items, errMsg) if errMsg then - return callback(nil, errMsg) + return resultCallback(nil, errMsg) end local fetchedItemIds = {} local idSet = {} @@ -172,23 +177,26 @@ function TradeQueryRequestsClass:SearchWithQueryWeightAdjusted(realm, league, qu end self:FetchResults(unfetchedItemIds, previousSearchId, function(newItems, errMsg) if errMsg then - return callback(nil, errMsg) + return resultCallback(nil, errMsg) end items = tableConcat(items, newItems) - callback(items, errMsg) + resultCallback(items, errMsg) end) else - callback(items, errMsg) + resultCallback(items, errMsg) end end) else -- Search not clipped and result count satisfy maxFetchPerSearch, proceed normally - self:FetchResults(response.result, response.id, callback) + self:FetchResults(response.result, response.id, resultCallback) end else if response.total < self.maxFetchPerSearch then -- Less than maximum items retrieved lower weight to try and get more. local queryJson = dkjson.decode(query) - queryJson.query.stats[1].value.min = queryJson.query.stats[1].value.min / 2 + if not queryJson.query.stats[1].value then + queryJson.query.stats[1].value = { min = 0 } + end + queryJson.query.stats[1].value.min = (queryJson.query.stats[1].value.min or 0) / 2 query = dkjson.encode(queryJson) self:PerformSearch(realm, league, query, performSearchCallback) else -- Search clipped, fetch highest weight item, update query weight and repeat search @@ -197,12 +205,15 @@ function TradeQueryRequestsClass:SearchWithQueryWeightAdjusted(realm, league, qu local firstResultBatch = {unpack(response.result, 1, math.min(#response.result, 10))} self:FetchResults(firstResultBatch, response.id, function(items, errMsg) if errMsg then - return callback(nil, errMsg) + return resultCallback(nil, errMsg) end previousSearchItems = items local highestWeight = items[1].weight local queryJson = dkjson.decode(query) - queryJson.query.stats[1].value.min = (tonumber(highestWeight) + queryJson.query.stats[1].value.min) / 2 + if not queryJson.query.stats[1].value then + queryJson.query.stats[1].value = { min = 0 } + end + queryJson.query.stats[1].value.min = (tonumber(highestWeight) + (queryJson.query.stats[1].value.min or 0)) / 2 query = dkjson.encode(queryJson) self:PerformSearch(realm, league, query, performSearchCallback) end) @@ -403,6 +414,9 @@ end ---@param callback fun(items:table, errMsg:string, query: string?) function TradeQueryRequestsClass:SearchWithURL(url, callback) local subpath = url:match(self.hostNamePattern .. "trade/search/(.+)$") + if not subpath then + return callback(nil, "Invalid URL") + end local paths = {} for path in subpath:gmatch("[^/]+") do table.insert(paths, path) @@ -414,54 +428,42 @@ function TradeQueryRequestsClass:SearchWithURL(url, callback) if #paths == 3 then realm = paths[1] end - league = paths[#paths-1] + league = urlDecode(paths[#paths-1]) queryId = paths[#paths] - self:FetchSearchQuery(realm, league, queryId, function(query, errMsg) - if errMsg then - return callback(nil, errMsg, nil) - end - - -- update sorting on provided url to sort by weights. - local json_data = dkjson.decode(query) - if not json_data or json_data.error then - errMsg = json_data and json_data.error or "Failed to parse search query JSON" - return callback(nil, errMsg, nil) + local json = tradeHelpers.B64GzipDecode(queryId) + if not json then + return callback(nil, "URL is malformed") + end + local decodedQuery = dkjson.decode(json) + if type(decodedQuery) ~= "table" or type(decodedQuery.stats) ~= "table" or type(decodedQuery.stats[1]) ~= "table" then + return callback(nil, "URL is malformed") + end + local weightGroupIndex + for i, group in ipairs(decodedQuery.stats) do + if type(group) ~= "table" or type(group.type) ~= "string" then + return callback(nil, "URL is malformed") end - if json_data.query.stats and json_data.query.stats[1] and json_data.query.stats[1].type == "weight" then - json_data.sort = {} - json_data.sort["statgroup.0"] = "desc" - else - json_data.sort = { price = "asc"} + if group.type == "weight" and not weightGroupIndex then + weightGroupIndex = i end - query = dkjson.encode(json_data) - - self:SearchWithQuery(realm, league, query, function(items, searchErrMsg) - callback(items, searchErrMsg, query) - end) + end + local newQuery = { + query = decodedQuery, + sort = { price = "asc" }, + engine = "new", + } + if weightGroupIndex then + -- Purchase links expect the weighted group first. + decodedQuery.stats[1], decodedQuery.stats[weightGroupIndex] = decodedQuery.stats[weightGroupIndex], decodedQuery.stats[1] + newQuery.sort = { ["statgroup.0"] = "desc" } + end + -- Pasted searches contain user constraints; only generated searches may adjust weights. + local query = dkjson.encode(newQuery) + self:SearchWithQuery(realm, league, query, function(items, errMsg) + callback(items, errMsg, query) end) end ----Fetch query data needed to perform the search ----@param queryId string ----@param league string ----@param callback fun(query:string, errMsg:string) -function TradeQueryRequestsClass:FetchSearchQuery(realm, league, queryId, callback) - local url = self:buildUrl(self.hostName .. "api/trade/search", realm, league, queryId) - table.insert(self.requestQueue["search"], { - url = url, - callback = function(response, errMsg) - if errMsg then - return callback(nil, errMsg) - end - local json_data = dkjson.decode(response) - if not json_data or json_data.error then - errMsg = json_data and json_data.error or "Failed to get search query" - end - callback(response, errMsg) - end - }) -end - --- Fetches the list of all available leagues using trade league API ---@param realm string ---@param callback fun(query:table, errMsg:string) diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index 842b3b4e09..0dcb91e393 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -15,8 +15,7 @@ local m_min = math.min local m_floor = math.floor local m_abs = math.abs local s_format = string.format -local s_gsub = string.gsub -local s_byte = string.byte +local tradeHelpers = require("Classes.TradeHelpers") local dkjson = require "dkjson" ---@class TreeTab: ControlHost @@ -2282,22 +2281,17 @@ function TreeTabClass:FindTimelessJewel() "any" } local search = { - query = { - status = { - option = tradeTypes[self.tradeTypeIndex] - }, - stats = { - { - filters = seedTrades, - type = "count", - value = { - min = 1 - } + status = { + option = tradeTypes[self.tradeTypeIndex] + }, + stats = { + { + filters = seedTrades, + type = "count", + value = { + min = 1 } } - }, - sort = { - price = "asc" } } @@ -2310,22 +2304,16 @@ function TreeTabClass:FindTimelessJewel() t_insert(devotionFilters, { id = data.timelessJewelTradeIDs[timelessData.jewelType.id].devotion[timelessData.sharedResults.devotionVariant2.id - 1] }) end if next(devotionFilters) then - t_insert(search.query.stats, { + t_insert(search.stats, { filters = devotionFilters, type = "and" }) end end - -- if the league was not selected via dropdown, then default to the first league in the dropdown or "" if the leagues could not be read - local selectedRealm = controls.realmSelection:GetSelValue():lower() - - local realmPath = selectedRealm == "pc" and "" or (selectedRealm .. "/") - local url = "https://www.pathofexile.com/trade/search/" .. realmPath .. - (controls.searchTradeLeagueSelect:GetSelValue()) .. - "/?q=" .. (s_gsub(dkjson.encode(search), "[^a-zA-Z0-9]", function(a) - return s_format("%%%02X", s_byte(a)) - end)) + local url = self.tradeQueryRequests:buildUrl("https://www.pathofexile.com/trade/search", + controls.realmSelection:GetSelValue():lower(), controls.searchTradeLeagueSelect:GetSelValue(), + tradeHelpers.B64GzipEncode(dkjson.encode(search))) OpenURL(url) Copy(url) diff --git a/src/_SimpleGraphic.def.lua b/src/_SimpleGraphic.def.lua index 6e159e4134..9336ee36c3 100644 --- a/src/_SimpleGraphic.def.lua +++ b/src/_SimpleGraphic.def.lua @@ -360,13 +360,15 @@ function Copy(text) end function Paste() end ---@param data string +---@param isGzip boolean? Whether a Gzip header should be used instead of the default ZLib header. ---@return string? compressedData ---@return string? errMsg -function Deflate(data) +function Deflate(data, isGzip) + -- TODO: add FFI bindings to `runtime/zlib1.dll` similar to what SimpleGraphic does return "" end ----@param data string +---@param data string DEFLATE data with either ZLib or Gzip headers. The format is detected automatically. Raw DEFLATE data is not supported. ---@return string? data ---@return string? errMsg function Inflate(data)