|
| 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