Skip to content

Commit 6d1f24d

Browse files
committed
add CompareBuySimilar.lua for logic related to buying similar items
1 parent c8f0160 commit 6d1f24d

2 files changed

Lines changed: 401 additions & 387 deletions

File tree

src/Classes/CompareBuySimilar.lua

Lines changed: 399 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,399 @@
1+
-- Path of Building
2+
--
3+
-- Module: Compare Buy Similar
4+
-- Buy Similar popup UI and trade search URL builder for the Compare tab.
5+
--
6+
local t_insert = table.insert
7+
local m_floor = math.floor
8+
local dkjson = require "dkjson"
9+
local tradeHelpers = LoadModule("Classes/CompareTradeHelpers")
10+
11+
local M = {}
12+
13+
-- Realm display name to API id mapping
14+
local REALM_API_IDS = {
15+
["PC"] = "pc",
16+
["PS4"] = "sony",
17+
["Xbox"] = "xbox",
18+
}
19+
20+
-- Listed status display names and their API option values
21+
local LISTED_STATUS_OPTIONS = {
22+
{ label = "Instant Buyout & In Person", apiValue = "available" },
23+
{ label = "Instant Buyout", apiValue = "securable" },
24+
{ label = "In Person (Online)", apiValue = "online" },
25+
{ label = "Any", apiValue = "any" },
26+
}
27+
local LISTED_STATUS_LABELS = { }
28+
for i, entry in ipairs(LISTED_STATUS_OPTIONS) do
29+
LISTED_STATUS_LABELS[i] = entry.label
30+
end
31+
32+
-- Helper: create a numeric EditControl without +/- spinner buttons
33+
local function newPlainNumericEdit(anchor, rect, init, prompt, limit)
34+
local ctrl = new("EditControl", anchor, rect, init, prompt, "%D", limit)
35+
-- Remove the +/- spinner buttons that "%D" filter triggers
36+
ctrl.isNumeric = false
37+
if ctrl.controls then
38+
if ctrl.controls.buttonDown then ctrl.controls.buttonDown.shown = false end
39+
if ctrl.controls.buttonUp then ctrl.controls.buttonUp.shown = false end
40+
end
41+
return ctrl
42+
end
43+
44+
-- Build the trade search URL based on popup selections
45+
local function buildURL(item, slotName, controls, modEntries, defenceEntries, isUnique)
46+
-- Determine realm and league from the popup's dropdowns
47+
local realmDisplayValue = controls.realmDrop and controls.realmDrop:GetSelValue() or "PC"
48+
local realm = REALM_API_IDS[realmDisplayValue] or "pc"
49+
local league = controls.leagueDrop and controls.leagueDrop:GetSelValue()
50+
if not league or league == "" or league == "Loading..." then
51+
league = "Standard"
52+
end
53+
local hostName = "https://www.pathofexile.com/"
54+
55+
-- Determine listed status from dropdown
56+
local listedIndex = controls.listedDrop and controls.listedDrop.selIndex or 1
57+
local listedApiValue = LISTED_STATUS_OPTIONS[listedIndex] and LISTED_STATUS_OPTIONS[listedIndex].apiValue or "available"
58+
59+
-- Build query
60+
local queryTable = {
61+
query = {
62+
status = { option = listedApiValue },
63+
stats = {
64+
{
65+
type = "and",
66+
filters = {}
67+
}
68+
},
69+
},
70+
sort = { price = "asc" }
71+
}
72+
local queryFilters = {}
73+
74+
if isUnique then
75+
-- Search by unique name
76+
-- Strip "Foulborn" prefix from unique name for trade search
77+
local tradeName = (item.title or item.name):gsub("^Foulborn%s+", "")
78+
queryTable.query.name = tradeName
79+
queryTable.query.type = item.baseName
80+
-- If item is Foulborn, add the foulborn_item filter
81+
if item.foulborn then
82+
queryFilters.misc_filters = queryFilters.misc_filters or { filters = {} }
83+
queryFilters.misc_filters.filters.foulborn_item = { option = "true" }
84+
end
85+
else
86+
-- Category filter
87+
local categoryStr = tradeHelpers.getTradeCategory(slotName, item)
88+
if categoryStr then
89+
queryFilters.type_filters = {
90+
filters = {
91+
category = { option = categoryStr }
92+
}
93+
}
94+
end
95+
96+
-- Base type filter
97+
if controls.baseTypeCheck and controls.baseTypeCheck.state then
98+
queryTable.query.type = item.baseName
99+
end
100+
101+
-- Item level filter
102+
local ilvlMin = controls.ilvlMin and tonumber(controls.ilvlMin.buf)
103+
local ilvlMax = controls.ilvlMax and tonumber(controls.ilvlMax.buf)
104+
if ilvlMin or ilvlMax then
105+
local ilvlFilter = {}
106+
if ilvlMin then ilvlFilter.min = ilvlMin end
107+
if ilvlMax then ilvlFilter.max = ilvlMax end
108+
queryFilters.misc_filters = {
109+
filters = {
110+
ilvl = ilvlFilter
111+
}
112+
}
113+
end
114+
115+
-- Defence stat filters
116+
local armourFilters = {}
117+
for i, def in ipairs(defenceEntries) do
118+
local prefix = "def" .. i
119+
if controls[prefix .. "Check"] and controls[prefix .. "Check"].state then
120+
local minVal = tonumber(controls[prefix .. "Min"].buf)
121+
local maxVal = tonumber(controls[prefix .. "Max"].buf)
122+
local filter = {}
123+
if minVal then filter.min = minVal end
124+
if maxVal then filter.max = maxVal end
125+
if minVal or maxVal then
126+
armourFilters[def.tradeKey] = filter
127+
end
128+
end
129+
end
130+
if next(armourFilters) then
131+
queryFilters.armour_filters = {
132+
filters = armourFilters
133+
}
134+
end
135+
end
136+
137+
-- Mod filters
138+
for i, entry in ipairs(modEntries) do
139+
local prefix = "mod" .. i
140+
if entry.tradeId and controls[prefix .. "Check"] and controls[prefix .. "Check"].state then
141+
local minVal = tonumber(controls[prefix .. "Min"].buf)
142+
local maxVal = tonumber(controls[prefix .. "Max"].buf)
143+
local filter = { id = entry.tradeId }
144+
local value = {}
145+
if minVal then value.min = minVal end
146+
if maxVal then value.max = maxVal end
147+
if next(value) then
148+
filter.value = value
149+
end
150+
t_insert(queryTable.query.stats[1].filters, filter)
151+
end
152+
end
153+
154+
-- Only include filters if we have any
155+
if next(queryFilters) then
156+
queryTable.query.filters = queryFilters
157+
end
158+
159+
-- Build URL
160+
local queryJson = dkjson.encode(queryTable)
161+
local url = hostName .. "trade/search"
162+
if realm and realm ~= "" and realm ~= "pc" then
163+
url = url .. "/" .. realm
164+
end
165+
local encodedLeague = league:gsub("[^%w%-%.%_%~]", function(c)
166+
return string.format("%%%02X", string.byte(c))
167+
end):gsub(" ", "+")
168+
url = url .. "/" .. encodedLeague
169+
url = url .. "?q=" .. urlEncode(queryJson)
170+
171+
return url
172+
end
173+
174+
-- Open the Buy Similar popup for a compared item
175+
function M.openPopup(item, slotName, primaryBuild)
176+
if not item then return end
177+
178+
local isUnique = item.rarity == "UNIQUE" or item.rarity == "RELIC"
179+
local controls = {}
180+
local rowHeight = 24
181+
local popupWidth = 700
182+
local leftMargin = 20
183+
local minFieldX = popupWidth - 160
184+
local maxFieldX = popupWidth - 80
185+
local fieldW = 60
186+
local fieldH = 20
187+
local checkboxSize = 20
188+
189+
-- Collect mod entries with trade IDs
190+
local modEntries = {}
191+
local modTypeSources = {
192+
{ list = item.implicitModLines, type = "implicit" },
193+
{ list = item.enchantModLines, type = "enchant" },
194+
{ list = item.scourgeModLines, type = "explicit" },
195+
{ list = item.explicitModLines, type = "explicit" },
196+
{ list = item.crucibleModLines, type = "explicit" },
197+
}
198+
for _, source in ipairs(modTypeSources) do
199+
if source.list then
200+
for _, modLine in ipairs(source.list) do
201+
if item:CheckModLineVariant(modLine) then
202+
local formatted = itemLib.formatModLine(modLine)
203+
if formatted then
204+
-- Use range-resolved text for matching
205+
local resolvedLine = (modLine.range and itemLib.applyRange(modLine.line, modLine.range, modLine.valueScalar)) or modLine.line
206+
local tradeId = tradeHelpers.findTradeModId(resolvedLine, source.type)
207+
local value = tradeHelpers.modLineValue(resolvedLine)
208+
t_insert(modEntries, {
209+
line = modLine.line,
210+
formatted = formatted:gsub("%^x%x%x%x%x%x%x", ""):gsub("%^%x", ""), -- strip color codes
211+
tradeId = tradeId,
212+
value = value,
213+
modType = source.type,
214+
})
215+
end
216+
end
217+
end
218+
end
219+
end
220+
221+
-- Collect defence stats for non-unique gear items
222+
local defenceEntries = {}
223+
if not isUnique and item.armourData and item.base and item.base.armour then
224+
local defences = {
225+
{ key = "Armour", label = "Armour", tradeKey = "ar" },
226+
{ key = "Evasion", label = "Evasion", tradeKey = "ev" },
227+
{ key = "EnergyShield", label = "Energy Shield", tradeKey = "es" },
228+
{ key = "Ward", label = "Ward", tradeKey = "ward" },
229+
}
230+
for _, def in ipairs(defences) do
231+
local val = item.armourData[def.key]
232+
if val and val > 0 then
233+
t_insert(defenceEntries, {
234+
label = def.label,
235+
value = val,
236+
tradeKey = def.tradeKey,
237+
})
238+
end
239+
end
240+
end
241+
242+
-- Build controls
243+
local ctrlY = 25
244+
245+
-- Realm and league dropdowns
246+
local tradeQuery = primaryBuild.itemsTab and primaryBuild.itemsTab.tradeQuery
247+
local tradeQueryRequests = tradeQuery and tradeQuery.tradeQueryRequests
248+
if not tradeQueryRequests then
249+
tradeQueryRequests = new("TradeQueryRequests")
250+
end
251+
252+
-- Helper to fetch and populate leagues for a given realm API id
253+
local function fetchLeaguesForRealm(realmApiId)
254+
controls.leagueDrop:SetList({"Loading..."})
255+
controls.leagueDrop.selIndex = 1
256+
tradeQueryRequests:FetchLeagues(realmApiId, function(leagues, errMsg)
257+
if errMsg then
258+
controls.leagueDrop:SetList({"Standard"})
259+
return
260+
end
261+
local leagueList = {}
262+
for _, league in ipairs(leagues) do
263+
if league ~= "Standard" and league ~= "Ruthless" and league ~= "Hardcore" and league ~= "Hardcore Ruthless" then
264+
if not (league:find("Hardcore") or league:find("Ruthless")) then
265+
t_insert(leagueList, 1, league)
266+
else
267+
t_insert(leagueList, league)
268+
end
269+
end
270+
end
271+
t_insert(leagueList, "Standard")
272+
t_insert(leagueList, "Hardcore")
273+
t_insert(leagueList, "Ruthless")
274+
t_insert(leagueList, "Hardcore Ruthless")
275+
controls.leagueDrop:SetList(leagueList)
276+
end)
277+
end
278+
279+
-- Realm dropdown
280+
controls.realmLabel = new("LabelControl", {"TOPLEFT", nil, "TOPLEFT"}, {leftMargin, ctrlY, 0, 16}, "^7Realm:")
281+
controls.realmDrop = new("DropDownControl", {"LEFT", controls.realmLabel, "RIGHT"}, {4, 0, 80, 20}, {"PC", "PS4", "Xbox"}, function(index, value)
282+
local realmApiId = REALM_API_IDS[value] or "pc"
283+
fetchLeaguesForRealm(realmApiId)
284+
end)
285+
286+
-- League dropdown
287+
controls.leagueLabel = new("LabelControl", {"LEFT", controls.realmDrop, "RIGHT"}, {12, 0, 0, 16}, "^7League:")
288+
controls.leagueDrop = new("DropDownControl", {"LEFT", controls.leagueLabel, "RIGHT"}, {4, 0, 160, 20}, {"Loading..."}, function(index, value)
289+
-- League selection stored in the dropdown itself
290+
end)
291+
controls.leagueDrop.enabled = function() return #controls.leagueDrop.list > 0 and controls.leagueDrop.list[1] ~= "Loading..." end
292+
293+
-- Listed status dropdown
294+
controls.listedLabel = new("LabelControl", {"LEFT", controls.leagueDrop, "RIGHT"}, {12, 0, 0, 16}, "^7Listed:")
295+
controls.listedDrop = new("DropDownControl", {"LEFT", controls.listedLabel, "RIGHT"}, {4, 0, 180, 20}, LISTED_STATUS_LABELS, function(index, value)
296+
-- Listed status selection stored in the dropdown itself
297+
end)
298+
299+
-- Fetch initial leagues for default realm
300+
fetchLeaguesForRealm("pc")
301+
ctrlY = ctrlY + rowHeight + 4
302+
303+
if isUnique then
304+
-- Unique item name label
305+
controls.nameLabel = new("LabelControl", nil, {0, ctrlY, 0, 16}, "^x" .. (colorCodes[item.rarity] or "FFFFFF"):gsub("%^x","") .. item.name)
306+
ctrlY = ctrlY + rowHeight
307+
else
308+
-- Category label
309+
local categoryLabel = tradeHelpers.getTradeCategoryLabel(slotName, item)
310+
controls.categoryLabel = new("LabelControl", {"TOPLEFT", nil, "TOPLEFT"}, {leftMargin, ctrlY, 0, 16}, "^7Category: " .. categoryLabel)
311+
ctrlY = ctrlY + rowHeight
312+
313+
-- Base type checkbox
314+
controls.baseTypeCheck = new("CheckBoxControl", nil, {-popupWidth/2 + leftMargin + checkboxSize/2, ctrlY, checkboxSize}, "", nil, nil)
315+
controls.baseTypeLabel = new("LabelControl", {"LEFT", controls.baseTypeCheck, "RIGHT"}, {4, 0, 0, 16}, "^7Use specific base: " .. (item.baseName or "Unknown"))
316+
ctrlY = ctrlY + rowHeight
317+
318+
-- Item level
319+
ctrlY = ctrlY + 4
320+
controls.ilvlLabel = new("LabelControl", {"TOPLEFT", nil, "TOPLEFT"}, {leftMargin, ctrlY, 0, 16}, "^7Item Level:")
321+
controls.ilvlMin = newPlainNumericEdit(nil, {minFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, "", "Min", 4)
322+
controls.ilvlMax = newPlainNumericEdit(nil, {maxFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, "", "Max", 4)
323+
ctrlY = ctrlY + rowHeight
324+
325+
-- Defence stat rows
326+
for i, def in ipairs(defenceEntries) do
327+
local prefix = "def" .. i
328+
controls[prefix .. "Check"] = new("CheckBoxControl", nil, {-popupWidth/2 + leftMargin + checkboxSize/2, ctrlY, checkboxSize}, "", nil, nil)
329+
controls[prefix .. "Label"] = new("LabelControl", {"LEFT", controls[prefix .. "Check"], "RIGHT"}, {4, 0, 0, 16}, "^7" .. def.label)
330+
controls[prefix .. "Min"] = newPlainNumericEdit(nil, {minFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, tostring(m_floor(def.value)), "Min", 6)
331+
controls[prefix .. "Max"] = newPlainNumericEdit(nil, {maxFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, "", "Max", 6)
332+
ctrlY = ctrlY + rowHeight
333+
end
334+
335+
-- Separator between defence stats and mods
336+
if #defenceEntries > 0 then
337+
ctrlY = ctrlY + 8
338+
end
339+
end
340+
341+
-- Mod rows
342+
for i, entry in ipairs(modEntries) do
343+
local prefix = "mod" .. i
344+
local canSearch = entry.tradeId ~= nil
345+
controls[prefix .. "Check"] = new("CheckBoxControl", nil, {-popupWidth/2 + leftMargin + checkboxSize/2, ctrlY, checkboxSize}, "", nil, nil)
346+
controls[prefix .. "Check"].enabled = function() return canSearch end
347+
-- Truncate long mod text to fit
348+
local displayText = entry.formatted
349+
if #displayText > 45 then
350+
displayText = displayText:sub(1, 42) .. "..."
351+
end
352+
controls[prefix .. "Label"] = new("LabelControl", {"LEFT", controls[prefix .. "Check"], "RIGHT"}, {4, 0, 0, 16}, (canSearch and "^7" or "^8") .. displayText)
353+
controls[prefix .. "Min"] = newPlainNumericEdit(nil, {minFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, entry.value ~= 0 and tostring(m_floor(entry.value)) or "", "Min", 8)
354+
controls[prefix .. "Max"] = newPlainNumericEdit(nil, {maxFieldX - popupWidth/2, ctrlY, fieldW, fieldH}, "", "Max", 8)
355+
if not canSearch then
356+
controls[prefix .. "Min"].enabled = function() return false end
357+
controls[prefix .. "Max"].enabled = function() return false end
358+
end
359+
ctrlY = ctrlY + rowHeight
360+
end
361+
362+
-- Search button
363+
ctrlY = ctrlY + 8
364+
controls.search = new("ButtonControl", nil, {0, ctrlY, 100, 20}, "Generate URL", function()
365+
local success, result = pcall(function()
366+
return buildURL(item, slotName, controls, modEntries, defenceEntries, isUnique)
367+
end)
368+
if success and result then
369+
controls.uri:SetText(result, true)
370+
elseif not success then
371+
controls.uri:SetText("Error: " .. tostring(result), true)
372+
else
373+
controls.uri:SetText("Error: could not determine league", true)
374+
end
375+
end)
376+
ctrlY = ctrlY + rowHeight + 4
377+
378+
-- URL field
379+
controls.uri = new("EditControl", nil, {-30, ctrlY, popupWidth - 100, fieldH}, "", nil, "^%C\t\n")
380+
controls.uri:SetPlaceholder("Press 'Generate URL' then Ctrl+Click to open")
381+
controls.uri.tooltipFunc = function(tooltip)
382+
tooltip:Clear()
383+
if controls.uri.buf and controls.uri.buf ~= "" then
384+
tooltip:AddLine(16, "^7Ctrl + Click to open in web browser")
385+
end
386+
end
387+
controls.close = new("ButtonControl", nil, {popupWidth/2 - 50, ctrlY, 60, 20}, "Close", function()
388+
main:ClosePopup()
389+
end)
390+
391+
-- Calculate popup height from final control position
392+
local popupHeight = ctrlY + fieldH + 16
393+
if popupHeight > 600 then popupHeight = 600 end
394+
395+
local title = "Buy Similar"
396+
main:OpenPopup(popupWidth, popupHeight, title, controls, "search", nil, "close")
397+
end
398+
399+
return M

0 commit comments

Comments
 (0)