diff --git a/docs/tradeSiteFiltering.md b/docs/tradeSiteFiltering.md new file mode 100644 index 0000000000..dd212f881f --- /dev/null +++ b/docs/tradeSiteFiltering.md @@ -0,0 +1,294 @@ +# Path of Exile Trade Site Filtering System + +## Overview + +The Path of Exile trade site uses a JSON-based query system to filter items. This document explains how the filtering works based on the Path of Building implementation. + +## URL Structure + +The trade site supports two URL formats: + +1. **Query ID format**: `https://www.pathofexile.com/trade/search/{league}/{queryId}` + - Example: `https://www.pathofexile.com/trade/search/Keepers/8rlgmrO8FV` + - The `queryId` (e.g., `8rlgmrO8FV`) is generated by the trade site when you submit a search + - This ID can be used to retrieve the search results without re-submitting the query + +2. **Encoded query format**: `https://www.pathofexile.com/trade/search/{league}/?q={encodedQuery}` + - The `q` parameter contains a URL-encoded JSON query object + - This format allows direct sharing of search parameters + +## Query JSON Structure + +The query is a JSON object with the following structure: + +```json +{ + "query": { + "filters": { + "type_filters": { + "filters": { + "category": { "option": "armour.chest" }, + "rarity": { "option": "nonunique" } + } + }, + "misc_filters": { + "disabled": false, + "filters": { + "mirrored": false + } + }, + "trade_filters": { + "filters": { + "price": { + "option": "chaos", + "max": 50 + } + } + }, + "socket_filters": { + "disabled": false, + "filters": { + "sockets": { + "min": 4, + "max": 6 + }, + "links": { + "min": 4, + "max": 6 + } + } + }, + "req_filters": { + "disabled": false, + "filters": { + "lvl": { + "max": 80 + } + } + } + }, + "status": { "option": "available" }, + "stats": [ + { + "type": "weight", + "value": { "min": 1000 }, + "filters": [ + { "id": "stat_id_1", "value": { "weight": 1.5 } }, + { "id": "stat_id_2", "value": { "weight": 2.0 } } + ] + } + ] + }, + "sort": { + "statgroup.0": "desc" + }, + "engine": "new" +} +``` + +## Key Components + +### 1. Type Filters +- **category**: Item category (e.g., `armour.chest`, `weapon.bow`, `accessory.ring`) +- **rarity**: Item rarity (`nonunique`, `unique`, etc.) + +### 2. Stat Filters +The `stats` array contains stat groups that can be: +- **type: "weight"**: Weighted search where each stat has a weight multiplier +- **type: "and"**: All stats in the group must match +- **type: "count"**: Count of matching stats +- **type: "if"**: Conditional stat matching + +Each stat filter has: +- **id**: The trade site's internal stat ID (fetched from `/api/trade/data/stats`) +- **value**: Can contain `min`, `max`, or `weight` depending on the filter type + +### 3. Trade Filters +- **price**: Maximum price in specified currency +- **listing_time**: How recently the item was listed + +### 4. Socket Filters +- **sockets**: Number of sockets (min/max) +- **links**: Number of linked sockets (min/max) + +### 5. Requirement Filters +- **lvl**: Maximum level requirement + +### 6. Miscellaneous Filters +- **mirrored**: Whether to include mirrored items +- **corrupted**: Corruption status +- **quality**: Item quality percentage + +## How Path of Building Generates Queries + +### 1. Stat ID Mapping +Path of Building fetches stat definitions from the trade API: +```lua +-- Fetches from: https://www.pathofexile.com/api/trade/data/stats +local tradeStats = fetchStats() +``` + +This provides a mapping between stat text (e.g., "+#% to Fire Resistance") and stat IDs used in queries. + +### 2. Query Generation Process + +1. **Mod Weight Calculation**: For each mod that can appear on the item: + - Creates a test item with that mod + - Calculates the stat difference compared to base item + - Assigns a weight based on configured stat priorities + +2. **Query Assembly**: + - Sets item category based on slot type + - Adds stat filters with calculated weights + - Applies user options (price, level, sockets, etc.) + +3. **URL Encoding**: The JSON query is URL-encoded using percent encoding: + ```lua + function urlEncode(str) + local charToHex = function(c) + return s_format("%%%02X", string.byte(c)) + end + return str:gsub("([^%w_%-.~])", charToHex) + end + ``` + +### 3. Example Query Generation + +From `TradeQueryGenerator.lua`: + +```lua +local queryTable = { + query = { + filters = { + type_filters = { + filters = { + category = { option = "armour.chest" }, + rarity = { option = "nonunique" } + } + } + }, + status = { option = "available" }, + stats = { + { + type = "weight", + value = { min = minWeight }, + filters = { } + } + } + }, + sort = { ["statgroup.0"] = "desc" }, + engine = "new" +} + +-- Add stat filters +for _, entry in pairs(self.modWeights) do + table.insert(queryTable.query.stats[1].filters, { + id = entry.tradeModId, + value = { weight = entry.weight } + }) +end + +local queryJson = dkjson.encode(queryTable) +local url = "https://www.pathofexile.com/trade/search/" .. league .. "/?q=" .. urlEncode(queryJson) +``` + +## Extracting Query from Query ID + +When you have a URL with a query ID (like `8rlgmrO8FV`), Path of Building extracts the query by: + +1. **Fetching the HTML page**: `https://www.pathofexile.com/trade/search/{league}/{queryId}` +2. **Extracting JSON from HTML**: The page contains embedded JSON in a specific pattern: + ```lua + local dataStr = response.body:match('require%(%["main"%].+ t%((.+)%);}%);}%);') + ``` +3. **Parsing the state**: The JSON contains a `state` object with the query: + ```lua + local data = dkjson.decode(dataStr) + local query = { query = data.state } + ``` + +## Stat Categories + +The trade site organizes stats into categories: +- **Explicit** (index 2): Regular item mods +- **Implicit** (index 3): Base item implicits +- **Corrupted** (index 3): Corrupted item mods +- **Scourge** (index 6): Scourge mods +- **Eater** (index 3): Eater of Worlds mods +- **Exarch** (index 3): Searing Exarch mods +- **Synthesis** (index 3): Synthesis mods +- **PassiveNode** (index 2): Passive tree nodes (for cluster jewels) + +## Weighted Searches + +Path of Building uses weighted searches to find items that improve your build: + +1. **Weight Calculation**: Each stat is weighted based on: + - How much it improves your build's key stats (DPS, EHP, etc.) + - User-configured stat priorities + - The stat's value range + +2. **Minimum Weight**: The query sets a minimum weight threshold: + ```lua + local minWeight = currentStatDiff * 0.5 + ``` + +3. **Sorting**: Results are sorted by weighted sum in descending order: + ```json + "sort": { "statgroup.0": "desc" } + ``` + +## Item Category Mapping + +Path of Building maps item slots to trade site categories: + +| Slot | Category | +|------|----------| +| Body Armour | `armour.chest` | +| Helmet | `armour.helmet` | +| Gloves | `armour.gloves` | +| Boots | `armour.boots` | +| Shield | `armour.shield` | +| Amulet | `accessory.amulet` | +| Ring | `accessory.ring` | +| Belt | `accessory.belt` | +| 1H Weapon | `weapon.one` | +| 2H Weapon | `weapon.two` | +| Bow | `weapon.bow` | +| Staff | `weapon.staff` | +| Jewel | `jewel` or `jewel.base` | +| Abyss Jewel | `jewel.abyss` | +| Flask | `flask` | + +## API Endpoints + +- **Search**: `POST https://www.pathofexile.com/api/trade/search/{realm}/{league}` + - Body: JSON query object + - Returns: `{ id: "queryId", result: ["itemHash1", ...], total: 1234 }` + +- **Fetch Items**: `GET https://www.pathofexile.com/api/trade/fetch/{itemHashes}?query={queryId}` + - Returns: Full item details + +- **Get Stats**: `GET https://www.pathofexile.com/api/trade/data/stats` + - Returns: List of all available stat filters with IDs + +## Example: Decoding a Query ID URL + +Given: `https://www.pathofexile.com/trade/search/Keepers/8rlgmrO8FV` + +1. Extract league: `Keepers` +2. Extract query ID: `8rlgmrO8FV` +3. Fetch HTML: `GET https://www.pathofexile.com/trade/search/Keepers/8rlgmrO8FV` +4. Extract JSON from HTML using regex pattern +5. Parse `data.state` to get the query object +6. The query object contains all the filters that were used in the original search + +## References + +- Trade Query Generator: `src/Classes/TradeQueryGenerator.lua` +- Trade Query Requests: `src/Classes/TradeQueryRequests.lua` +- Trade Query: `src/Classes/TradeQuery.lua` +- URL Encoding: `src/Modules/Common.lua` (urlEncode function) + + + diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index c830d9c96a..84e7b7c777 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -108,7 +108,8 @@ local ItemsTabClass = newClass("ItemsTab", "UndoHandler", "ControlHost", "Contro self.slots = { } self.orderedSlots = { } self.slotOrder = { } - self.slotAnchor = new("Control", {"TOPLEFT",self,"TOPLEFT"}, {96, 76, 310, 0}) + -- Increased width to accommodate trade buttons (310 slot + 4 gap + 60 button = 374, add extra margin) + self.slotAnchor = new("Control", {"TOPLEFT",self,"TOPLEFT"}, {96, 76, 440, 0}) local prevSlot = self.slotAnchor local function addSlot(slot) prevSlot = slot @@ -120,6 +121,12 @@ local ItemsTabClass = newClass("ItemsTab", "UndoHandler", "ControlHost", "Contro for index, slotName in ipairs(baseSlots) do local slot = new("ItemSlotControl", {"TOPLEFT",prevSlot,"BOTTOMLEFT"}, 0, 2, self, slotName) addSlot(slot) + -- Add trade button next to each slot + local tradeButton = new("ButtonControl", {"LEFT",slot,"RIGHT"}, {4, 0, 60, 20}, "Trade", function() + self:OpenLeagueSelectionPopup(slot) + end) + tradeButton.tooltipText = "Generate a trade search filtered by this item's mods" + t_insert(self.controls, tradeButton) if slotName:match("Weapon") then -- Add alternate weapon slot slot.weaponSet = 1 @@ -128,6 +135,15 @@ local ItemsTabClass = newClass("ItemsTab", "UndoHandler", "ControlHost", "Contro end local swapSlot = new("ItemSlotControl", {"TOPLEFT",prevSlot,"BOTTOMLEFT"}, 0, 2, self, slotName.." Swap", slotName) addSlot(swapSlot) + -- Add trade button for second weapon set + local swapTradeButton = new("ButtonControl", {"LEFT",swapSlot,"RIGHT"}, {4, 0, 60, 20}, "Trade", function() + self:OpenLeagueSelectionPopup(swapSlot) + end) + swapTradeButton.tooltipText = "Generate a trade search filtered by this item's mods" + swapTradeButton.shown = function() + return self.activeItemSet.useSecondWeaponSet + end + t_insert(self.controls, swapTradeButton) swapSlot.weaponSet = 2 swapSlot.shown = function() return self.activeItemSet.useSecondWeaponSet @@ -194,9 +210,15 @@ local ItemsTabClass = newClass("ItemsTab", "UndoHandler", "ControlHost", "Contro local socketControl = new("ItemSlotControl", {"TOPLEFT",prevSlot,"BOTTOMLEFT"}, 0, 2, self, "Jewel "..node.id, "Socket", node.id) self.sockets[node.id] = socketControl addSlot(socketControl) + -- Add trade button for socket/jewel slots + local socketTradeButton = new("ButtonControl", {"LEFT",socketControl,"RIGHT"}, {4, 0, 60, 20}, "Trade", function() + self:OpenLeagueSelectionPopup(socketControl) + end) + socketTradeButton.tooltipText = "Generate a trade search filtered by this item's mods" + t_insert(self.controls, socketTradeButton) end self.controls.slotHeader = new("LabelControl", {"BOTTOMLEFT",self.slotAnchor,"TOPLEFT"}, {0, -4, 0, 16}, "^7Equipped items:") - self.controls.weaponSwap1 = new("ButtonControl", {"BOTTOMRIGHT",self.slotAnchor,"TOPRIGHT"}, {-20, -2, 18, 18}, "I", function() + self.controls.weaponSwap1 = new("ButtonControl", {"BOTTOMLEFT",self.slotAnchor,"TOPLEFT"}, {310, -2, 18, 18}, "I", function() if self.activeItemSet.useSecondWeaponSet then self.activeItemSet.useSecondWeaponSet = false self:AddUndoState() @@ -216,7 +238,7 @@ local ItemsTabClass = newClass("ItemsTab", "UndoHandler", "ControlHost", "Contro self.controls.weaponSwap1.locked = function() return not self.activeItemSet.useSecondWeaponSet end - self.controls.weaponSwap2 = new("ButtonControl", {"BOTTOMRIGHT",self.slotAnchor,"TOPRIGHT"}, {0, -2, 18, 18}, "II", function() + self.controls.weaponSwap2 = new("ButtonControl", {"LEFT",self.controls.weaponSwap1,"RIGHT"}, {2, 0, 18, 18}, "II", function() if not self.activeItemSet.useSecondWeaponSet then self.activeItemSet.useSecondWeaponSet = true self:AddUndoState() @@ -239,10 +261,14 @@ local ItemsTabClass = newClass("ItemsTab", "UndoHandler", "ControlHost", "Contro self.controls.weaponSwapLabel = new("LabelControl", {"RIGHT",self.controls.weaponSwap1,"LEFT"}, {-4, 0, 0, 14}, "^7Weapon Set:") -- All items list + -- Position to avoid overlapping trade buttons (slot 310 + gap 4 + button 60 = 374, add margin) if main.portraitMode then - self.controls.itemList = new("ItemListControl", {"TOPRIGHT",self.lastSlot,"BOTTOMRIGHT"}, {0, 0, 360, 308}, self, true) + -- Position itemList to the right of trade buttons (slot 310 + gap 4 + button 60 + margin = 80px from slot right edge) + self.controls.itemList = new("ItemListControl", {"TOPRIGHT",self.lastSlot,"BOTTOMRIGHT"}, {80, 0, 360, 308}, self, true) else - self.controls.itemList = new("ItemListControl", {"TOPLEFT",self.controls.setManage,"TOPRIGHT"}, {20, 20, 360, 308}, self, true) + -- In landscape mode, position relative to top of tab to avoid trade buttons + -- slotAnchor x=96, slot width=310, gap=4, button=60, so start at 96+310+4+60+margin = 470+margin + self.controls.itemList = new("ItemListControl", {"TOPLEFT",self,"TOPLEFT"}, {480, 28, 360, 308}, self, true) end -- Database selector @@ -1202,7 +1228,9 @@ function ItemsTabClass:Draw(viewPort, inputEvents) self.controls.scrollBarV.y = viewPort.y do local maxY = select(2, self.lastSlot:GetPos()) + 24 - local maxX = self.anchorDisplayItem:GetPos() + 462 + -- Calculate maxX to include trade buttons (slot width 310 + gap 4 + button width 60 = 374, add margin) + local slotMaxX = self.slotAnchor:GetPos() + 440 + local maxX = m_max(self.anchorDisplayItem:GetPos() + 462, slotMaxX) if self.displayItem then local x, y = self.controls.displayItemTooltipAnchor:GetPos() local ttW, ttH = self.displayItemTooltip:GetDynamicSize(viewPort) @@ -1426,7 +1454,8 @@ function ItemsTabClass:UpdateSockets() end if main.portraitMode then - self.controls.itemList:SetAnchor("TOPRIGHT",self.lastSlot,"BOTTOMRIGHT", 0, 40) + -- Keep itemList positioned to avoid trade buttons (80px from slot right edge) + self.controls.itemList:SetAnchor("TOPRIGHT",self.lastSlot,"BOTTOMRIGHT", 80, 0) end end @@ -4037,3 +4066,702 @@ function ItemsTabClass:RestoreUndoState(state) self.activeItemSet = self.itemSets[self.activeItemSetId] self:PopulateSlots() end + +function ItemsTabClass:GetTradeStatWeights() + if not self.tradeQuery.statSortSelectionList or #self.tradeQuery.statSortSelectionList == 0 then + self.tradeQuery.statSortSelectionList = { + { label = "Full DPS", stat = "FullDPS", weightMult = 1.0 }, + { label = "Effective Hit Pool", stat = "TotalEHP", weightMult = 0.5 }, + } + end + return self.tradeQuery.statSortSelectionList +end + +function ItemsTabClass:GetSlotTradeOptions(slot) + local options = { } + local item = slot and self.items[slot.selItemId] + if item then + options.includeCorrupted = not item.corrupted + options.includeScourge = item.scourge or false + options.includeTalisman = slot.slotName == "Amulet" and item.talismanTier ~= nil + if slot.slotName == "Body Armour" or slot.slotName == "Helmet" or slot.slotName == "Gloves" or slot.slotName == "Boots" then + options.includeEldritch = item.exarch or item.eater + end + local influenceIndexes = {} + for index, influenceInfo in ipairs(itemLib.influenceInfo.default) do + if item[influenceInfo.key] then + t_insert(influenceIndexes, index + 1) + if #influenceIndexes == 2 then + break + end + end + end + options.influence1 = influenceIndexes[1] or 1 + options.influence2 = influenceIndexes[2] or 1 + else + options.includeCorrupted = true + options.includeScourge = false + options.includeEldritch = false + options.includeTalisman = false + options.influence1 = 1 + options.influence2 = 1 + end + if slot.slotName:find("Abyssal") then + options.jewelType = "Abyss" + elseif slot.slotName:find("Jewel") then + options.jewelType = "Any" + end + return options +end + +function ItemsTabClass:StoreTradeURL(slot, url) + if not self.activeItemSet then + return + end + if slot.nodeId then + self.activeItemSet[slot.nodeId] = self.activeItemSet[slot.nodeId] or { } + self.activeItemSet[slot.nodeId].pbURL = url + elseif self.activeItemSet[slot.slotName] then + self.activeItemSet[slot.slotName].pbURL = url + end +end + +function ItemsTabClass:OpenTradeForSlot(slot) + if not slot then + return + end + if not self.tradeQuery then + main:OpenMessagePopup("Trade Search", "^1Trade query system not initialized.") + return + end + -- Initialize tradeQueryGenerator if it doesn't exist + if not self.tradeQuery.tradeQueryGenerator then + self.tradeQuery.tradeQueryGenerator = new("TradeQueryGenerator", self.tradeQuery) + main.onFrameFuncs["TradeQueryGenerator"] = function() + if self.tradeQuery.tradeQueryGenerator then + self.tradeQuery.tradeQueryGenerator:OnFrame() + end + end + end + if not self.tradeQuery.tradeQueryGenerator.modData then + main:OpenMessagePopup("Trade Search", "^1Trade query data not loaded. Please wait a moment and try again.") + return + end + -- Always use the newest/current league (first in the list, which is sorted with newest first) + local pbLeague + local realm = (self.tradeQuery.pbRealm and self.tradeQuery.pbRealm ~= "") and self.tradeQuery.pbRealm or "pc" + + -- Try to get the newest league from leagueDropList first (sorted with newest first) + if self.leagueDropList and #self.leagueDropList > 0 then + -- First league in the list is the newest/current league + pbLeague = self.leagueDropList[1] + elseif self.tradeQuery.allLeagues and self.tradeQuery.allLeagues[realm] and #self.tradeQuery.allLeagues[realm] > 0 then + -- Fallback to allLeagues - first one should be newest (non-SSF leagues come first) + pbLeague = self.tradeQuery.allLeagues[realm][1] + else + -- Last resort: use Standard as fallback + pbLeague = "Standard" + end + self.tradeQuery.pbLeague = pbLeague + -- Only generate query if there's an actual item equipped + local item = slot and self.items[slot.selItemId] + if not item or not item.base then + main:OpenMessagePopup("Trade Search", "^1No item equipped in this slot. Please equip an item first.") + return + end + + local statWeights = self:GetTradeStatWeights() + local options = self:GetSlotTradeOptions(slot) + -- Show a brief message that we're generating the query + main:OpenMessagePopup("Trade Search", "^7Generating trade query for " .. (slot.slotName or "item") .. "...") + self.tradeQuery.tradeQueryGenerator:RequestQuickQuery(slot, options, statWeights, function(context, query, errMsg) + -- Close the "generating" popup + if main.popups[1] then + main:ClosePopup() + end + if errMsg then + main:OpenMessagePopup("Trade Search", colorCodes.NEGATIVE .. errMsg) + return + end + if not query or query == "" then + main:OpenMessagePopup("Trade Search", "^1Failed to build trade query.") + return + end + local url = self.tradeQuery.tradeQueryRequests:buildUrl(self.tradeQuery.hostName .. "trade/search", realm, pbLeague) + url = url .. "?q=" .. urlEncode(query) + self:StoreTradeURL(slot, url) + OpenURL(url) + end) +end + +-- Open league selection popup for trade link generation +function ItemsTabClass:OpenLeagueSelectionPopup(slot) + if not slot then + return + end + + -- Get the item first (needed for validation) + local item = slot and self.items[slot.selItemId] + if not item or not item.base then + main:OpenMessagePopup("Trade Search", "^1No item equipped in this slot. Please equip an item first.") + return + end + + -- Function to open the popup with league list + local function openPopupWithLeagues(leagueList) + if not leagueList or #leagueList == 0 then + main:OpenMessagePopup("Trade Search", "^1No leagues available. Please try again later.") + return + end + + local controls = {} + local rowHeight = 22 + local startY = 20 + local maxVisibleRows = 15 + local popupHeight = startY + (m_min(#leagueList, maxVisibleRows) * rowHeight) + 60 + local popupWidth = 320 + + -- Create a label + controls.label = new("LabelControl", nil, {0, startY, 0, 16}, "^7Select a league:") + + -- Create buttons for each league (limit to visible rows for now) + local buttonY = startY + 25 + local visibleLeagues = m_min(#leagueList, maxVisibleRows) + for i = 1, visibleLeagues do + local league = leagueList[i] + controls["league_" .. i] = new("ButtonControl", nil, {0, buttonY, popupWidth - 40, rowHeight}, league, function() + main:ClosePopup() + -- Generate trade link with selected league + self:OpenTradeForSlotWithItemMods(slot, league) + end) + buttonY = buttonY + rowHeight + 2 + end + + -- Show message if there are more leagues + if #leagueList > maxVisibleRows then + controls.moreLabel = new("LabelControl", nil, {0, buttonY, 0, 16}, "^7... and " .. (#leagueList - maxVisibleRows) .. " more (showing first " .. maxVisibleRows .. ")") + buttonY = buttonY + 20 + end + + -- Cancel button + controls.cancel = new("ButtonControl", nil, {0, buttonY + 5, 80, 20}, "Cancel", function() + main:ClosePopup() + end) + + main:OpenPopup(popupWidth, popupHeight, "Select League", controls, nil, nil, "cancel") + end + + -- Get league list + local leagueList = nil + if self.tradeQuery.itemsTab and self.tradeQuery.itemsTab.leagueDropList and #self.tradeQuery.itemsTab.leagueDropList > 0 then + leagueList = self.tradeQuery.itemsTab.leagueDropList + elseif self.leagueDropList and #self.leagueDropList > 0 then + leagueList = self.leagueDropList + end + + if leagueList and #leagueList > 0 then + openPopupWithLeagues(leagueList) + else + -- Fetch leagues first + launch:DownloadPage( + self.tradeQuery.hostName .. "api/leagues?type=main&compact=1", + function(response, errMsg) + if errMsg then + main:OpenMessagePopup("Trade Search", "^1Error fetching league list: " .. tostring(errMsg)) + return + else + local dkjson = require "dkjson" + local json_data = dkjson.decode(response.body) + if not json_data then + main:OpenMessagePopup("Trade Search", "^1Failed to Get PoE League List response") + return + end + -- Sort leagues: temporary leagues (with endAt) first, sorted by startAt (newest first) + table.sort(json_data, function(a, b) + if a.endAt == nil then return false end + if b.endAt == nil then return true end + if a.startAt and b.startAt then + return a.startAt > b.startAt + end + return #a.id < #b.id + end) + -- Store leagues + local fetchedLeagueList = {} + for _, league_data in ipairs(json_data) do + if not league_data.id:find("SSF") then + t_insert(fetchedLeagueList, league_data.id) + end + end + -- Update stored lists + self.leagueDropList = fetchedLeagueList + if self.tradeQuery.itemsTab then + self.tradeQuery.itemsTab.leagueDropList = fetchedLeagueList + end + -- Open popup with fetched leagues + openPopupWithLeagues(fetchedLeagueList) + end + end) + end +end + +-- Generate a trade link with filters based on the current item's mods +function ItemsTabClass:OpenTradeForSlotWithItemMods(slot, selectedLeague) + if not slot then + return + end + if not self.tradeQuery then + main:OpenMessagePopup("Trade Search", "^1Trade query system not initialized.") + return + end + -- Initialize tradeQueryGenerator if it doesn't exist + if not self.tradeQuery.tradeQueryGenerator then + self.tradeQuery.tradeQueryGenerator = new("TradeQueryGenerator", self.tradeQuery) + main.onFrameFuncs["TradeQueryGenerator"] = function() + if self.tradeQuery.tradeQueryGenerator then + self.tradeQuery.tradeQueryGenerator:OnFrame() + end + end + end + if not self.tradeQuery.tradeQueryGenerator.modData then + main:OpenMessagePopup("Trade Search", "^1Trade query data not loaded. Please wait a moment and try again.") + return + end + + -- Get the item first (needed for both unique and non-unique paths) + local item = slot and self.items[slot.selItemId] + if not item or not item.base then + main:OpenMessagePopup("Trade Search", "^1No item equipped in this slot. Please equip an item first.") + return + end + + -- Use PullLeagueList to get the newest league (first in the list) + local realm = (self.tradeQuery.pbRealm and self.tradeQuery.pbRealm ~= "") and self.tradeQuery.pbRealm or "pc" + + -- Use selected league if provided, otherwise get from list + local pbLeague = selectedLeague + + -- Function to generate the query once we have the league + local function generateQueryWithLeague(pbLeague) + ConPrintf("=== generateQueryWithLeague called with league: %s ===", pbLeague or "NIL") + self.tradeQuery.pbLeague = pbLeague + ConPrintf("=== self.tradeQuery.pbLeague set to: %s ===", self.tradeQuery.pbLeague or "NIL") + + -- If item is unique, use name-based search instead of mod-based search + if item.rarity == "UNIQUE" and item.name then + -- Determine item category for unique items + local itemCategoryQueryStr + local existingItem = item + if slot.slotName:find("^Weapon %d") then + if existingItem.type == "Shield" then + itemCategoryQueryStr = "armour.shield" + elseif existingItem.type == "Quiver" then + itemCategoryQueryStr = "armour.quiver" + elseif existingItem.type == "Bow" then + itemCategoryQueryStr = "weapon.bow" + elseif existingItem.type == "Staff" then + itemCategoryQueryStr = "weapon.staff" + elseif existingItem.type == "Two Handed Sword" then + itemCategoryQueryStr = "weapon.twosword" + elseif existingItem.type == "Two Handed Axe" then + itemCategoryQueryStr = "weapon.twoaxe" + elseif existingItem.type == "Two Handed Mace" then + itemCategoryQueryStr = "weapon.twomace" + elseif existingItem.type == "Fishing Rod" then + itemCategoryQueryStr = "weapon.rod" + elseif existingItem.type == "One Handed Sword" then + itemCategoryQueryStr = "weapon.onesword" + elseif existingItem.type == "One Handed Axe" then + itemCategoryQueryStr = "weapon.oneaxe" + elseif existingItem.type == "One Handed Mace" or existingItem.type == "Sceptre" then + itemCategoryQueryStr = "weapon.onemace" + elseif existingItem.type == "Wand" then + itemCategoryQueryStr = "weapon.wand" + elseif existingItem.type == "Dagger" then + itemCategoryQueryStr = "weapon.dagger" + elseif existingItem.type == "Claw" then + itemCategoryQueryStr = "weapon.claw" + elseif existingItem.type:find("Two Handed") ~= nil then + itemCategoryQueryStr = "weapon.twomelee" + elseif existingItem.type:find("One Handed") ~= nil then + itemCategoryQueryStr = "weapon.one" + else + main:OpenMessagePopup("Trade Search", "^1Item type not supported: " .. (existingItem.type or "unknown")) + return + end + elseif slot.slotName == "Body Armour" then + itemCategoryQueryStr = "armour.chest" + elseif slot.slotName == "Helmet" then + itemCategoryQueryStr = "armour.helmet" + elseif slot.slotName == "Gloves" then + itemCategoryQueryStr = "armour.gloves" + elseif slot.slotName == "Boots" then + itemCategoryQueryStr = "armour.boots" + elseif slot.slotName == "Amulet" then + itemCategoryQueryStr = "accessory.amulet" + elseif slot.slotName == "Ring 1" or slot.slotName == "Ring 2" or slot.slotName == "Ring 3" then + itemCategoryQueryStr = "accessory.ring" + elseif slot.slotName == "Belt" then + itemCategoryQueryStr = "accessory.belt" + elseif slot.slotName:find("Abyssal") ~= nil then + itemCategoryQueryStr = "jewel.abyss" + elseif slot.slotName:find("Jewel") ~= nil then + itemCategoryQueryStr = "jewel.base" + elseif slot.slotName:find("Flask") ~= nil then + itemCategoryQueryStr = "flask" + else + main:OpenMessagePopup("Trade Search", "^1Slot type not supported: " .. (slot.slotName or "unknown")) + return + end + + -- Build query for unique item using name + -- Unique item names are typically in format "Unique Name, Base Type" + -- Extract the unique name and base type + local uniqueName = item.name + local baseType = item.type or item.baseName + + -- If name contains a comma, split it (format: "Unique Name, Base Type") + if item.name:find(",") then + local parts = {} + for part in item.name:gmatch("([^,]+)") do + t_insert(parts, part:match("^%s*(.-)%s*$")) -- trim whitespace + end + if #parts >= 2 then + uniqueName = parts[1] + baseType = parts[2] + end + end + + local queryTable = { + query = { + filters = { + type_filters = { + filters = { + category = { option = itemCategoryQueryStr }, + rarity = { option = "unique" } + } + } + }, + status = { option = "available" }, + name = uniqueName, + type = baseType + }, + sort = { price = "asc" }, + engine = "new" + } + + -- Add misc filters + local miscFilters = {} + + -- Add mirrored filter if item is not mirrored + if not item.mirrored then + miscFilters.mirrored = false + end + + -- Add corrupted filter if item is corrupted (if not corrupted, don't add filter = "Any") + if item.corrupted then + miscFilters.corrupted = true + end + + -- Only add misc_filters if there's at least one filter + if next(miscFilters) then + queryTable.query.filters.misc_filters = { + disabled = false, + filters = miscFilters + } + end + + -- Encode and build URL + local dkjson = require "dkjson" + local queryJson = dkjson.encode(queryTable) + -- Replace any {league} placeholder with the actual league name + if pbLeague then + queryJson = queryJson:gsub("{league}", pbLeague) + end + ConPrintf("=== Building URL for unique item ===") + ConPrintf("=== pbLeague parameter: %s ===", pbLeague or "NIL") + ConPrintf("=== self.tradeQuery.pbLeague: %s ===", self.tradeQuery.pbLeague or "NIL") + ConPrintf("=== realm: %s ===", realm or "NIL") + local url = self.tradeQuery.tradeQueryRequests:buildUrl(self.tradeQuery.hostName .. "trade/search", realm, pbLeague) + url = url .. "?q=" .. urlEncode(queryJson) + ConPrintf("=== Generated URL: %s ===", url) + + self:StoreTradeURL(slot, url) + OpenURL(url) + return + end + + -- Determine item category for non-unique items + local itemCategoryQueryStr + local existingItem = item + if slot.slotName:find("^Weapon %d") then + if existingItem.type == "Shield" then + itemCategoryQueryStr = "armour.shield" + elseif existingItem.type == "Quiver" then + itemCategoryQueryStr = "armour.quiver" + elseif existingItem.type == "Bow" then + itemCategoryQueryStr = "weapon.bow" + elseif existingItem.type == "Staff" then + itemCategoryQueryStr = "weapon.staff" + elseif existingItem.type == "Two Handed Sword" then + itemCategoryQueryStr = "weapon.twosword" + elseif existingItem.type == "Two Handed Axe" then + itemCategoryQueryStr = "weapon.twoaxe" + elseif existingItem.type == "Two Handed Mace" then + itemCategoryQueryStr = "weapon.twomace" + elseif existingItem.type == "Fishing Rod" then + itemCategoryQueryStr = "weapon.rod" + elseif existingItem.type == "One Handed Sword" then + itemCategoryQueryStr = "weapon.onesword" + elseif existingItem.type == "One Handed Axe" then + itemCategoryQueryStr = "weapon.oneaxe" + elseif existingItem.type == "One Handed Mace" or existingItem.type == "Sceptre" then + itemCategoryQueryStr = "weapon.onemace" + elseif existingItem.type == "Wand" then + itemCategoryQueryStr = "weapon.wand" + elseif existingItem.type == "Dagger" then + itemCategoryQueryStr = "weapon.dagger" + elseif existingItem.type == "Claw" then + itemCategoryQueryStr = "weapon.claw" + elseif existingItem.type:find("Two Handed") ~= nil then + itemCategoryQueryStr = "weapon.twomelee" + elseif existingItem.type:find("One Handed") ~= nil then + itemCategoryQueryStr = "weapon.one" + else + main:OpenMessagePopup("Trade Search", "^1Item type not supported: " .. (existingItem.type or "unknown")) + return + end + elseif slot.slotName == "Body Armour" then + itemCategoryQueryStr = "armour.chest" + elseif slot.slotName == "Helmet" then + itemCategoryQueryStr = "armour.helmet" + elseif slot.slotName == "Gloves" then + itemCategoryQueryStr = "armour.gloves" + elseif slot.slotName == "Boots" then + itemCategoryQueryStr = "armour.boots" + elseif slot.slotName == "Amulet" then + itemCategoryQueryStr = "accessory.amulet" + elseif slot.slotName == "Ring 1" or slot.slotName == "Ring 2" or slot.slotName == "Ring 3" then + itemCategoryQueryStr = "accessory.ring" + elseif slot.slotName == "Belt" then + itemCategoryQueryStr = "accessory.belt" + elseif slot.slotName:find("Abyssal") ~= nil then + itemCategoryQueryStr = "jewel.abyss" + elseif slot.slotName:find("Jewel") ~= nil then + itemCategoryQueryStr = "jewel.base" + elseif slot.slotName:find("Flask") ~= nil then + itemCategoryQueryStr = "flask" + else + main:OpenMessagePopup("Trade Search", "^1Slot type not supported: " .. (slot.slotName or "unknown")) + return + end + + -- Collect mod lines from the item + local modLinesToProcess = {} + -- Add explicit mods + for _, modLine in ipairs(item.explicitModLines) do + if modLine.line and not modLine.line:find("Grants Level") then + t_insert(modLinesToProcess, { line = modLine.line, type = "Explicit" }) + end + end + -- Add implicit mods + for _, modLine in ipairs(item.implicitModLines) do + if modLine.line and not modLine.line:find("Grants Level") then + t_insert(modLinesToProcess, { line = modLine.line, type = "Implicit" }) + end + end + -- Add corrupted mods (if corrupted) + if item.corrupted then + for _, modLine in ipairs(item.explicitModLines) do + if modLine.line and not modLine.line:find("Grants Level") then + t_insert(modLinesToProcess, { line = modLine.line, type = "Corrupted" }) + end + end + end + -- Add scourge mods + for _, modLine in ipairs(item.scourgeModLines) do + if modLine.line and not modLine.line:find("Grants Level") then + t_insert(modLinesToProcess, { line = modLine.line, type = "Scourge" }) + end + end + -- Add eater/exarch mods + for _, modLine in ipairs(item.explicitModLines) do + if modLine.line and modLine.eater then + t_insert(modLinesToProcess, { line = modLine.line, type = "Eater" }) + elseif modLine.line and modLine.exarch then + t_insert(modLinesToProcess, { line = modLine.line, type = "Exarch" }) + end + end + + -- Match mod lines to trade stat IDs + local statFilters = {} + local modData = self.tradeQuery.tradeQueryGenerator.modData + local tradeStatCategoryIndices = { + ["Explicit"] = 2, + ["Implicit"] = 3, + ["Corrupted"] = 3, + ["Scourge"] = 6, + ["Eater"] = 3, + ["Exarch"] = 3, + ["Synthesis"] = 3, + } + + for _, modEntry in ipairs(modLinesToProcess) do + local modLine = modEntry.line + local modType = modEntry.type + + -- Skip if mod type not supported + if not modData[modType] then + goto continue + end + + -- Try to match the mod line to a trade stat + local matchStr = modLine:gsub("[#()0-9%-%+%.]","") + local foundMatch = false + + -- Search through all modData entries for this type + for uniqueIndex, modDataEntry in pairs(modData[modType]) do + if modDataEntry.tradeMod then + local tradeModText = modDataEntry.tradeMod.text:gsub("[#()0-9%-%+%.]","") + -- Check for exact match or match with (Local) suffix + local localMatchStr = matchStr .. " (Local)" + local tradeModTextNoLocal = tradeModText:gsub(" %(Local%)", "") + + if tradeModText == matchStr or tradeModText == localMatchStr or tradeModTextNoLocal == matchStr then + -- Found a match! Extract the numeric value(s) + local minValue = nil + local maxValue = nil + + -- Try to extract numeric values from mod line + -- Pattern for single value: "+50" or "50" or "-10" + -- Pattern for range: "(5-10)" or "5-10" + local singleValuePattern = "([%+%-]?)(%d+%.?%d*)" + local rangePattern = "%(([%+%-]?)(%d+%.?%d*)%-([%+%-]?)(%d+%.?%d*)%)" + local rangePattern2 = "([%+%-]?)(%d+%.?%d*)%-([%+%-]?)(%d+%.?%d*)" + + local sign1, val1, sign2, val2 = modLine:match(rangePattern) + if val1 and val2 then + -- Range value found + minValue = tonumber(val1) + maxValue = tonumber(val2) + if sign1 == "-" then minValue = -minValue end + if sign2 == "-" then maxValue = -maxValue end + else + sign1, val1, sign2, val2 = modLine:match(rangePattern2) + if val1 and val2 then + minValue = tonumber(val1) + maxValue = tonumber(val2) + if sign1 == "-" then minValue = -minValue end + if sign2 == "-" then maxValue = -maxValue end + else + -- Single value + local sign, value = modLine:match(singleValuePattern) + if value then + minValue = tonumber(value) + if sign == "-" then + minValue = -minValue + end + maxValue = minValue + end + end + end + + -- Add to stat filters + if minValue then + local filterValue = { min = minValue } + if maxValue and maxValue ~= minValue then + filterValue.max = maxValue + end + t_insert(statFilters, { + id = modDataEntry.tradeMod.id, + value = filterValue + }) + else + -- Boolean mod (no value) + t_insert(statFilters, { + id = modDataEntry.tradeMod.id + }) + end + foundMatch = true + break + end + end + end + + ::continue:: + end + + if #statFilters == 0 then + main:OpenMessagePopup("Trade Search", "^1Could not match any mods to trade stats. Try using the weighted search instead.") + return + end + + -- Build the query + local queryTable = { + query = { + filters = { + type_filters = { + filters = { + category = { option = itemCategoryQueryStr }, + rarity = { option = item.rarity == "UNIQUE" and "unique" or "nonunique" } + } + } + }, + status = { option = "available" }, + stats = { + { + type = "and", + filters = statFilters + } + } + }, + sort = { price = "asc" }, + engine = "new" + } + + -- Add misc filters + local miscFilters = {} + + -- Add mirrored filter if item is not mirrored + if not item.mirrored then + miscFilters.mirrored = false + end + + -- Add corrupted filter if item is corrupted (if not corrupted, don't add filter = "Any") + if item.corrupted then + miscFilters.corrupted = true + end + + -- Only add misc_filters if there's at least one filter + if next(miscFilters) then + queryTable.query.filters.misc_filters = { + disabled = false, + filters = miscFilters + } + end + + -- Encode and build URL + local dkjson = require "dkjson" + local queryJson = dkjson.encode(queryTable) + -- Replace any {league} placeholder with the actual league name + if pbLeague then + queryJson = queryJson:gsub("{league}", pbLeague) + end + ConPrintf("=== Building URL for non-unique item ===") + ConPrintf("=== pbLeague parameter: %s ===", pbLeague or "NIL") + ConPrintf("=== self.tradeQuery.pbLeague: %s ===", self.tradeQuery.pbLeague or "NIL") + ConPrintf("=== realm: %s ===", realm or "NIL") + local url = self.tradeQuery.tradeQueryRequests:buildUrl(self.tradeQuery.hostName .. "trade/search", realm, pbLeague) + url = url .. "?q=" .. urlEncode(queryJson) + ConPrintf("=== Generated URL: %s ===", url) + + self:StoreTradeURL(slot, url) + OpenURL(url) + end + + -- Generate query with the selected league + if pbLeague then + generateQueryWithLeague(pbLeague) + else + main:OpenMessagePopup("Trade Search", "^1No league selected.") + end +end diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 6ecd0929a7..b871adbf7f 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -100,13 +100,23 @@ function TradeQueryClass:PullLeagueList() self:SetNotice(self.controls.pbNotice, "Failed to Get PoE League List response") return end + -- Sort leagues: temporary leagues (with endAt) first, sorted by startAt (newest first) + -- Permanent leagues (without endAt) go to the end table.sort(json_data, function(a, b) + -- If a is permanent (no endAt), it goes to the end if a.endAt == nil then return false end + -- If b is permanent (no endAt), a comes first if b.endAt == nil then return true end + -- Both are temporary leagues, sort by startAt (newest first) + if a.startAt and b.startAt then + return a.startAt > b.startAt + end + -- Fallback: sort by id length (shorter names often indicate newer leagues) return #a.id < #b.id end) self.itemsTab.leagueDropList = {} - for _, league_data in pairs(json_data) do + -- Use ipairs to preserve the sorted order + for _, league_data in ipairs(json_data) do if not league_data.id:find("SSF") then t_insert(self.itemsTab.leagueDropList,league_data.id) end diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index d7f4734f67..63707c77a2 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -863,10 +863,12 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) -- OnFrame will pick this up and begin the work self.calcContext.co = coroutine.create(self.ExecuteQuery) - -- Open progress tracking blocker popup - local controls = { } - controls.progressText = new("LabelControl", {"TOP",nil,"TOP"}, {0, 30, 0, 16}, string.format("Calculating Mod Weights...")) - self.calcContext.popup = main:OpenPopup(280, 65, "Please Wait", controls) + -- Open progress tracking blocker popup (skip for quick queries) + if not options.skipPopup then + local controls = { } + controls.progressText = new("LabelControl", {"TOP",nil,"TOP"}, {0, 30, 0, 16}, string.format("Calculating Mod Weights...")) + self.calcContext.popup = main:OpenPopup(280, 65, "Please Wait", controls) + end end function TradeQueryGeneratorClass:ExecuteQuery() @@ -1048,8 +1050,10 @@ function TradeQueryGeneratorClass:FinishQuery() local queryJson = dkjson.encode(queryTable) self.requesterCallback(self.requesterContext, queryJson, errMsg) - -- Close blocker popup - main:ClosePopup() + -- Close blocker popup (if it was opened) + if self.calcContext.popup then + main:ClosePopup() + end end function TradeQueryGeneratorClass:RequestQuery(slot, context, statWeights, callback) @@ -1240,4 +1244,44 @@ function TradeQueryGeneratorClass:RequestQuery(slot, context, statWeights, callb main:ClosePopup() end) main:OpenPopup(400, popupHeight, "Query Options", controls) +end + +function TradeQueryGeneratorClass:RequestQuickQuery(slot, options, statWeights, callback) + self.requesterCallback = callback + self.requesterContext = options and options.context or nil + local quickOptions = copyTable(options or {}, true) + quickOptions.statWeights = statWeights and copyTable(statWeights, true) or { + { label = "Full DPS", stat = "FullDPS", weightMult = 1.0 }, + { label = "Effective Hit Pool", stat = "TotalEHP", weightMult = 0.5 }, + } + if not quickOptions.statWeights or #quickOptions.statWeights == 0 then + quickOptions.statWeights = { + { label = "Full DPS", stat = "FullDPS", weightMult = 1.0 }, + { label = "Effective Hit Pool", stat = "TotalEHP", weightMult = 0.5 }, + } + end + quickOptions.includeMirrored = quickOptions.includeMirrored ~= false + if quickOptions.includeCorrupted == nil then + quickOptions.includeCorrupted = true + end + if quickOptions.includeScourge == nil then + quickOptions.includeScourge = false + end + if quickOptions.includeEldritch == nil then + quickOptions.includeEldritch = false + end + if quickOptions.includeTalisman == nil then + quickOptions.includeTalisman = false + end + quickOptions.influence1 = quickOptions.influence1 or 1 + quickOptions.influence2 = quickOptions.influence2 or 1 + if slot and slot.slotName and slot.slotName:find("Jewel") and not quickOptions.jewelType then + if slot.slotName:find("Abyssal") then + quickOptions.jewelType = "Abyss" + else + quickOptions.jewelType = "Any" + end + end + quickOptions.skipPopup = true + self:StartQuery(slot, quickOptions) end \ No newline at end of file