Skip to content

Commit 6589172

Browse files
vaisestWires77
andauthored
Trader tool: OAuth support, match mods by tradeHash (fixes many local and essence mods), add price sorting, add radius jewels, notice when rate limited (#1801)
* port compatible trader tool changes from pob1 * Fix corrupted mods being fractured mods in trade mod generation * Fix radius jewel weight generation * Regenerate QueryMods.lua * convert trade tool mod weight generation to use tradeHash * wip: change poesessid to bearer token * Cleanup: remove extra logout button and fix poeapi comments * Fix trader crash when rate limited on startup * Use https://poe.ninja/poe2/api/economy/exchange/current/overview for currency rates * Fix poe.ninja tests * Fix trader section anchor * Adjust price scaling factor due to things being in divs (still an abitrary number) * Clarify price options and rate limit waits, and use Retry-After for rate limiting waits if possible * rate limiting pls work * Fix perfect essences not appearing in generated weights, and regenerate querymods.lua * Fix tradehashes for radius jewels * Improve rate limit countdown to prevent simplegraphic suspension problems, and to show it on non-429 rate limit. (429 issue solved on GGG's side) * Fix debug print causing crash, and remove extra debug print * disable wiping trader controls to fix crash when it is closed and a search tries to add results to controls * Add note about doing weird filter requirements (e.g. adorned) * remove whisper for instant buyout items * make cspell happy * Fix database radius jewels being nonfunctional * Fix currency conversion button not being updated after reopening trader panel * Avoid useless search in "search for" button * fix api error on invalid token * disable reuseaddr * Rework OAuth server launch code to avoid shared port usage * Remove error code on login and fix hanging item slot controls in tradequery * Fix QueryMods.lua generation. Change soulcores.lua to export trade hashes in a similar format as previous mod export changes. * Add note about trade hashes * Fix curse querymod.lua test * Clear authorization on 403 to fix outdated scope in trader * Use enum in mods.lua and format it * Fix typo in mods.lua * Fix module being a global variable and compare trade helpers into one file * Remove forgotten debug prints * Fix trade hash generation * add note about radius jewel mods * Fix rune weight generation and regenerate QueryMods.lua * Fix whitespace in related files * Fix error handling on oauth login * Fix price sum sorting in trade * Move print call so it doesn't get spammed in tests --------- Co-authored-by: Wires77 <Wires77@users.noreply.github.com>
1 parent 445ade0 commit 6589172

31 files changed

Lines changed: 12405 additions & 13173 deletions

runtime/lua/socket.lua

Lines changed: 96 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -17,53 +17,56 @@ local _M = socket
1717
-- Exported auxiliar functions
1818
-----------------------------------------------------------------------------
1919
function _M.connect4(address, port, laddress, lport)
20-
return socket.connect(address, port, laddress, lport, "inet")
20+
return socket.connect(address, port, laddress, lport, "inet")
2121
end
2222

2323
function _M.connect6(address, port, laddress, lport)
24-
return socket.connect(address, port, laddress, lport, "inet6")
24+
return socket.connect(address, port, laddress, lport, "inet6")
2525
end
2626

2727
function _M.bind(host, port, backlog)
28-
if host == "*" then host = "0.0.0.0" end
29-
local addrinfo, err = socket.dns.getaddrinfo(host);
30-
if not addrinfo then return nil, err end
31-
local sock, res
32-
err = "no info on address"
33-
for i, alt in base.ipairs(addrinfo) do
34-
if alt.family == "inet" then
35-
sock, err = socket.tcp4()
36-
else
37-
sock, err = socket.tcp6()
38-
end
39-
if not sock then return nil, err end
40-
sock:setoption("reuseaddr", true)
41-
res, err = sock:bind(alt.addr, port)
42-
if not res then
43-
sock:close()
44-
else
45-
res, err = sock:listen(backlog)
46-
if not res then
47-
sock:close()
48-
else
49-
return sock
50-
end
51-
end
52-
end
53-
return nil, err
28+
if host == "*" then host = "0.0.0.0" end
29+
local addrinfo, err = socket.dns.getaddrinfo(host);
30+
if not addrinfo then return nil, err end
31+
local sock, res
32+
err = "no info on address"
33+
for i, alt in base.ipairs(addrinfo) do
34+
if alt.family == "inet" then
35+
sock, err = socket.tcp4()
36+
else
37+
sock, err = socket.tcp6()
38+
end
39+
if not sock then return nil, err end
40+
-- sock:setoption("reuseaddr", true)
41+
res, err = sock:bind(alt.addr, port)
42+
if not res then
43+
sock:close()
44+
else
45+
res, err = sock:listen(backlog)
46+
if not res then
47+
sock:close()
48+
else
49+
return sock
50+
end
51+
end
52+
end
53+
return nil, err
5454
end
5555

5656
_M.try = _M.newtry()
5757

5858
function _M.choose(table)
59-
return function(name, opt1, opt2)
60-
if base.type(name) ~= "string" then
61-
name, opt1, opt2 = "default", name, opt1
62-
end
63-
local f = table[name or "nil"]
64-
if not f then base.error("unknown key (".. base.tostring(name) ..")", 3)
65-
else return f(opt1, opt2) end
66-
end
59+
return function(name, opt1, opt2)
60+
if base.type(name) ~= "string" then
61+
name, opt1, opt2 = "default", name, opt1
62+
end
63+
local f = table[name or "nil"]
64+
if not f then
65+
base.error("unknown key (" .. base.tostring(name) .. ")", 3)
66+
else
67+
return f(opt1, opt2)
68+
end
69+
end
6770
end
6871

6972
-----------------------------------------------------------------------------
@@ -77,68 +80,76 @@ _M.sinkt = sinkt
7780
_M.BLOCKSIZE = 2048
7881

7982
sinkt["close-when-done"] = function(sock)
80-
return base.setmetatable({
81-
getfd = function() return sock:getfd() end,
82-
dirty = function() return sock:dirty() end
83-
}, {
84-
__call = function(self, chunk, err)
85-
if not chunk then
86-
sock:close()
87-
return 1
88-
else return sock:send(chunk) end
89-
end
90-
})
83+
return base.setmetatable({
84+
getfd = function() return sock:getfd() end,
85+
dirty = function() return sock:dirty() end
86+
}, {
87+
__call = function(self, chunk, err)
88+
if not chunk then
89+
sock:close()
90+
return 1
91+
else
92+
return sock:send(chunk)
93+
end
94+
end
95+
})
9196
end
9297

9398
sinkt["keep-open"] = function(sock)
94-
return base.setmetatable({
95-
getfd = function() return sock:getfd() end,
96-
dirty = function() return sock:dirty() end
97-
}, {
98-
__call = function(self, chunk, err)
99-
if chunk then return sock:send(chunk)
100-
else return 1 end
101-
end
102-
})
99+
return base.setmetatable({
100+
getfd = function() return sock:getfd() end,
101+
dirty = function() return sock:dirty() end
102+
}, {
103+
__call = function(self, chunk, err)
104+
if chunk then
105+
return sock:send(chunk)
106+
else
107+
return 1
108+
end
109+
end
110+
})
103111
end
104112

105113
sinkt["default"] = sinkt["keep-open"]
106114

107115
_M.sink = _M.choose(sinkt)
108116

109117
sourcet["by-length"] = function(sock, length)
110-
return base.setmetatable({
111-
getfd = function() return sock:getfd() end,
112-
dirty = function() return sock:dirty() end
113-
}, {
114-
__call = function()
115-
if length <= 0 then return nil end
116-
local size = math.min(socket.BLOCKSIZE, length)
117-
local chunk, err = sock:receive(size)
118-
if err then return nil, err end
119-
length = length - string.len(chunk)
120-
return chunk
121-
end
122-
})
118+
return base.setmetatable({
119+
getfd = function() return sock:getfd() end,
120+
dirty = function() return sock:dirty() end
121+
}, {
122+
__call = function()
123+
if length <= 0 then return nil end
124+
local size = math.min(socket.BLOCKSIZE, length)
125+
local chunk, err = sock:receive(size)
126+
if err then return nil, err end
127+
length = length - string.len(chunk)
128+
return chunk
129+
end
130+
})
123131
end
124132

125133
sourcet["until-closed"] = function(sock)
126-
local done
127-
return base.setmetatable({
128-
getfd = function() return sock:getfd() end,
129-
dirty = function() return sock:dirty() end
130-
}, {
131-
__call = function()
132-
if done then return nil end
133-
local chunk, err, partial = sock:receive(socket.BLOCKSIZE)
134-
if not err then return chunk
135-
elseif err == "closed" then
136-
sock:close()
137-
done = 1
138-
return partial
139-
else return nil, err end
140-
end
141-
})
134+
local done
135+
return base.setmetatable({
136+
getfd = function() return sock:getfd() end,
137+
dirty = function() return sock:dirty() end
138+
}, {
139+
__call = function()
140+
if done then return nil end
141+
local chunk, err, partial = sock:receive(socket.BLOCKSIZE)
142+
if not err then
143+
return chunk
144+
elseif err == "closed" then
145+
sock:close()
146+
done = 1
147+
return partial
148+
else
149+
return nil, err
150+
end
151+
end
152+
})
142153
end
143154

144155

Lines changed: 50 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,82 @@
11
describe("TradeQuery Currency Conversion", function()
2-
local mock_tradeQuery = new("TradeQuery", { itemsTab = {} })
2+
local mock_tradeQuery
33

4-
-- test case for commit: "Skip callback on errors to prevent incomplete conversions"
5-
describe("FetchCurrencyConversionTable", function()
6-
-- Pass: Callback not called on error
7-
-- Fail: Callback called, indicating partial data risk
8-
it("skips callback on error", function()
9-
local orig_launch = launch
10-
local spy = { called = false }
11-
launch = {
12-
DownloadPage = function(url, callback, opts)
13-
callback(nil, "test error")
14-
end
15-
}
16-
mock_tradeQuery:FetchCurrencyConversionTable(function()
17-
spy.called = true
18-
end)
19-
launch = orig_launch
20-
assert.is_false(spy.called)
21-
end)
4+
before_each(function()
5+
mock_tradeQuery = new("TradeQuery", { itemsTab = {} })
226
end)
237

24-
describe("ConvertCurrencyToChaos", function()
25-
-- Pass: Ceils amount to integer (e.g., 4.9 -> 5)
26-
-- Fail: Wrong value or nil, indicating broken rounding/baseline logic, causing inaccurate chaos totals
8+
describe("ConvertCurrencyToDivs", function()
9+
-- Pass: Calculates price in divs
10+
-- Fail: Wrong value or nil, indicating broken rounding/baseline logic
2711
it("handles chaos currency", function()
28-
mock_tradeQuery.pbCurrencyConversion = { league = { chaos = 1 } }
12+
mock_tradeQuery.pbCurrencyConversion = { league = { chaos = 0.1 } }
2913
mock_tradeQuery.pbLeague = "league"
30-
local result = mock_tradeQuery:ConvertCurrencyToChaos("chaos", 4.9)
31-
assert.are.equal(result, 5)
14+
local result = mock_tradeQuery:ConvertCurrencyToDivs("chaos", 5)
15+
assert.are.equal(result, 0.5)
3216
end)
3317

3418
-- Pass: Returns nil without crash
3519
-- Fail: Crashes or wrong value, indicating unhandled currencies, corrupting price conversions
3620
it("returns nil for unmapped", function()
37-
local result = mock_tradeQuery:ConvertCurrencyToChaos("exotic", 10)
21+
local result = mock_tradeQuery:ConvertCurrencyToDivs("exotic", 10)
3822
assert.is_nil(result)
3923
end)
4024
end)
4125

4226
describe("PriceBuilderProcessPoENinjaResponse", function()
43-
-- Pass: Processes without error, restoring map
27+
-- Pass: Processes without error, restoring map while adding a notice
4428
-- Fail: Corrupts map or crashes, indicating fragile API response handling, breaking future conversions
45-
it("handles unmapped currency", function()
29+
it("handles empty response", function()
4630
local orig_conv = mock_tradeQuery.currencyConversionTradeMap
4731
mock_tradeQuery.currencyConversionTradeMap = { div = "id" }
48-
local resp = { exotic = 10 }
49-
mock_tradeQuery:PriceBuilderProcessPoENinjaResponse(resp)
32+
mock_tradeQuery.pbLeague = "league"
33+
mock_tradeQuery.pbCurrencyConversion = { league = {} }
34+
mock_tradeQuery.controls.pbNotice = { label = "" }
35+
local resp = { lines = { }}
36+
mock_tradeQuery:PriceBuilderProcessPoENinjaResponse(resp.lines)
5037
-- No crash expected
5138
assert.is_true(true)
39+
assert.is_true(mock_tradeQuery.controls.pbNotice.label == "No currencies received from PoE Ninja")
40+
mock_tradeQuery.currencyConversionTradeMap = orig_conv
41+
end)
42+
43+
-- Pass: Processes without error, restoring map while adding a notice
44+
-- Fail: Corrupts map or crashes, indicating fragile API response handling, breaking future conversions
45+
it("handles empty response", function()
46+
local orig_conv = mock_tradeQuery.currencyConversionTradeMap
47+
mock_tradeQuery.currencyConversionTradeMap = { div = "id" }
48+
mock_tradeQuery.pbLeague = "league"
49+
mock_tradeQuery.pbCurrencyConversion = { league = {} }
50+
mock_tradeQuery.controls.pbNotice = { label = "" }
51+
local resp = { lines = { { malformedLine = "lol"} }}
52+
mock_tradeQuery:PriceBuilderProcessPoENinjaResponse(resp.lines)
53+
-- No crash expected
54+
assert.is_true(true)
55+
assert.is_true(mock_tradeQuery.controls.pbNotice.label == "Currencies not updated: malformed PoE Ninja response")
5256
mock_tradeQuery.currencyConversionTradeMap = orig_conv
5357
end)
5458
end)
5559

5660
describe("GetTotalPriceString", function()
57-
-- Pass: Sums and formats correctly (e.g., "5 chaos, 10 div")
61+
-- Pass: Sums and formats correctly (e.g., "5 chaos, 10 div", should be most valuable currency first)
5862
-- Fail: Wrong string (e.g., unsorted/missing sums), indicating aggregation bug, misleading users on totals
5963
it("aggregates prices", function()
60-
mock_tradeQuery.totalPrice = { { currency = "chaos", amount = 5 }, { currency = "div", amount = 10 } }
64+
-- check alphabetical sorting
65+
mock_tradeQuery.totalPrice = { { currency = "chaos", amount = 5 }, { currency = "div", amount = 10 }, {currency = "exalted", amount = 1} }
66+
local result = mock_tradeQuery:GetTotalPriceString()
67+
assert.are.equal(result, "1 exalted, 10 div, 5 chaos")
68+
69+
-- check if they're sorted according to currency value
70+
mock_tradeQuery.pbLeague = "league"
71+
mock_tradeQuery.pbCurrencyConversion = { league = { chaos = 0.1, exalted = 0.05, div = 1, mirror = 700} }
72+
local result = mock_tradeQuery:GetTotalPriceString()
73+
assert.are.equal(result, "10 div, 5 chaos, 1 exalted")
74+
75+
-- check that missing currency values don't crash
76+
mock_tradeQuery.pbLeague = "league"
77+
mock_tradeQuery.pbCurrencyConversion = { league = { chaos = 0.1, exalted = 0.05, mirror = 700 } }
6178
local result = mock_tradeQuery:GetTotalPriceString()
62-
assert.are.equal(result, "5 chaos, 10 div")
79+
assert.True(true)
6380
end)
6481
end)
6582
end)

spec/System/TestTradeQueryGenerator_spec.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ describe("TradeQueryGenerator", function()
55
-- Pass: Mod line maps correctly to trade stat entry without error
66
-- Fail: Mapping fails (e.g., no match found), indicating incomplete stat parsing for curse mods, potentially missing curse-enabling items in queries
77
it("handles special curse case", function()
8-
local mod = { "You can apply an additional Curse" }
9-
local tradeStatsParsed = { result = { [2] = { entries = { { text = "You can apply # additional Curses", id = "id" } } } } }
8+
local mod = { tradeHashes = {[30642521] = {"You can apply an additional Curse"}} }
9+
local tradeStatsParsed = { result = { [2] = { entries = { { text = "You can apply # additional Curses", id = "explicit.stat_30642521" } } } } }
1010
mock_queryGen.modData = { Explicit = true }
1111
mock_queryGen:ProcessMod(mod, tradeStatsParsed, 1)
1212
-- Simplified assertion; in full impl, check modData

spec/System/TestTradeQueryRequests_spec.lua

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,42 @@ describe("TradeQueryRequests", function()
6565
launch = orig_launch
6666
end)
6767

68+
-- Pass: Does not crash on 401, and passes error message
69+
-- Fail: Crash, or returned error is wrong
70+
it("does not crash on 401", function()
71+
local json = '"{"error":"invalid_token","error_description":"The access token provided is invalid or has expired"}"'
72+
local header = [[HTTP/1.1 401 Unauthorized
73+
Date: Fri, 24 Apr 2026 07:30:38 GMT
74+
Content-Type: application/json
75+
Transfer-Encoding: chunked
76+
Connection: keep-alive
77+
Server: cloudflare
78+
WWW-Authenticate: Bearer realm="pathofexile:production", error="invalid_token", error_description="The access token provided is invalid or has expired"
79+
Cache-Control: no-store
80+
Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]]
81+
local orig_launch = launch
82+
launch = {
83+
DownloadPage = function(url, onComplete, opts)
84+
onComplete({ body = json, header = header }, nil)
85+
end
86+
}
87+
table.insert(requests.requestQueue.search, {
88+
url = "test",
89+
callback = function(body, msg)
90+
assert.are.equal(body, json)
91+
assert.truthy(msg:find("Response code: 401"))
92+
end,
93+
retryTime = nil
94+
})
95+
local function mock_next_time(self, policy, time)
96+
return time - 1
97+
end
98+
mock_limiter.NextRequestTime = mock_next_time
99+
requests:ProcessQueue()
100+
assert.are.equal(#requests.requestQueue.search, 0)
101+
launch = orig_launch
102+
end)
103+
68104
-- Pass: Retries with increasing backoff up to cap, preventing infinite loops
69105
-- Fail: No backoff or uncapped, indicating retry bug, risking API bans
70106
it("retries on 429 with exponential backoff", function()

0 commit comments

Comments
 (0)