From 5fe2d49dd5eb070c6e3e557352d61d26ccd20b19 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 17 Apr 2026 00:52:06 +0200 Subject: [PATCH 1/4] feat(trade): filter by attribute requirements in Trader pane Adds an "Include unusable" checkbox (off by default) to the Trader pane that hides search results whose Str/Dex/Int (or Omni) requirements the build cannot meet once equipped. Filtering is applied across all sort modes and cached per result to avoid redundant calcFunc calls. When filtering drops every result, the dropdown and total-price state are cleared and a dedicated notice is shown. Adds a matching "Attributes Requirements" checkbox (on by default) to the TradeQueryGenerator popup. When enabled, the shortfall (build requirement minus build attribute) is inserted as pseudo.pseudo_total_* min filters in the generated query so trade search only returns items that cover the missing attributes. Also hardens UI state transitions around filtered results: the result dropdown selection callback guards against stale indices, the section anchor preserves its base Y when the scrollbar offsets it, and empty sorted results no longer crash UpdateDropdownList / UpdateControlsWithItems. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Classes/TradeQuery.lua | 189 +++++++++++++++++++++++----- src/Classes/TradeQueryGenerator.lua | 37 ++++++ 2 files changed, 194 insertions(+), 32 deletions(-) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 7ea2fcedf0..ce37e5b3d5 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -38,6 +38,7 @@ function TradeQueryClass:TradeQuery(itemsTab) -- default set of trade item sort selection self.slotTables = { } self.pbItemSortSelectionIndex = 1 + self.hideResultsFailingAttributeRequirements = false -- for each realm and league, a table of values of each currency in div --- @type table>> self.pbCurrencyConversion = {} @@ -457,6 +458,20 @@ Highest Weight - Displays the order retrieved from trade]] self.controls.itemSortSelection:SetSel(self.pbItemSortSelectionIndex, true) self.controls.itemSortSelectionLabel = new("LabelControl"):LabelControl({"TOPRIGHT", self.controls.itemSortSelection, "TOPLEFT"}, {-4, 0, 56, 16}, "^7Sort By:") + -- Hide fetched results that would leave unmet attribute requirements unless unchecked. + local hideAttributeRequirementsLabel = "^7Hide results failing attribute requirements" + local hideAttributeRequirementsLabelWidth = DrawStringWidth(row_height - 4, "VAR", hideAttributeRequirementsLabel) + 5 + local hideAttributeRequirementsRect = {24 + hideAttributeRequirementsLabelWidth, 0, row_height, row_height} + self.controls.hideAttributeRequirementsCheck = new("CheckBoxControl"):CheckBoxControl({"LEFT", self.controls.tradeTypeSelection, "RIGHT"}, hideAttributeRequirementsRect, hideAttributeRequirementsLabel, function(state) + self.hideResultsFailingAttributeRequirements = state + for row_idx, _ in pairs(self.resultTbl) do + self:UpdateControlsWithItems(row_idx) + end + end) + self.controls.hideAttributeRequirementsCheck.tooltipText = "Hide fetched results when equipping the item would leave unmet Str/Dex/Int/Omniscience attribute requirements.\nUnchecked: show those results after fetching." + self.hideResultsFailingAttributeRequirements = self.hideResultsFailingAttributeRequirements == true + self.controls.hideAttributeRequirementsCheck.state = self.hideResultsFailingAttributeRequirements + -- Realm selection self.controls.realmLabel = new("LabelControl"):LabelControl({"LEFT", self.controls.setSelect, "RIGHT"}, {18, 0, 20, row_height - 4}, "^7Realm:") self.controls.realm = new("DropDownControl"):DropDownControl({"LEFT", self.controls.realmLabel, "RIGHT"}, {6, 0, 150, row_height}, self.realmDropList, function(index, value) @@ -558,7 +573,9 @@ Highest Weight - Displays the order retrieved from trade]] t_insert(slotTables, { slotName = self.itemsTab.sockets[nodeId].label, nodeId = nodeId }) end - self.controls.sectionAnchor = new("LabelControl"):LabelControl({"LEFT", self.controls.tradeTypeSelection, "LEFT"}, {0, row_vertical_padding, 0, 0}, "") + -- Base Y offset for sectionAnchor (used to preserve position when scrollbar shifts it) + local sectionAnchorBaseY = row_vertical_padding + row_height + self.controls.sectionAnchor = new("LabelControl"):LabelControl({"LEFT", self.controls.tradeTypeSelection, "LEFT"}, {0, sectionAnchorBaseY, 0, 0}, "") top_pane_alignment_ref = {"TOPLEFT", self.controls.sectionAnchor, "TOPLEFT"} local scrollBarShown = #slotTables > 21 -- clipping starts beyond this -- dynamically hide rows that are above or below the scrollBar @@ -633,7 +650,7 @@ Highest Weight - Displays the order retrieved from trade]] local function scrollBarFunc() self.controls.scrollBar.height = self.pane_height-100 self.controls.scrollBar:SetContentDimension(self.pane_height-100, self.effective_rows_height) - self.controls.sectionAnchor.y = -self.controls.scrollBar.offset + self.controls.sectionAnchor.y = sectionAnchorBaseY - self.controls.scrollBar.offset end local function onRateLimit(backoff) @@ -749,7 +766,7 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) for row_idx in pairs(self.resultTbl) do self:UpdateControlsWithItems(row_idx) end - end) + end) controls.cancel = new("ButtonControl"):ButtonControl({ "BOTTOM", nil, "BOTTOM" }, { 0, -10, 80, 20 }, "Cancel", function() if previousSelectionList and #previousSelectionList > 0 then self.statSortSelectionList = copyTable(previousSelectionList, true) @@ -795,6 +812,26 @@ function TradeQueryClass:ReduceOutput(output) return smallOutput end +function TradeQueryClass:GetReplacementSlotName(row_idx) + local slotTbl = self.slotTables[row_idx] + if not slotTbl then + return nil + end + local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId + if jewelNodeId then + return "Jewel " .. tostring(jewelNodeId) + end + if slotTbl.replacementSlotName then + return slotTbl.replacementSlotName + end + if slotTbl.fullName then + return slotTbl.fullName + end + if self.itemsTab.slots and self.itemsTab.slots[slotTbl.slotName] then + return slotTbl.slotName + end +end + -- Method to evaluate a result by getting it's output and weight function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, baseOutput) local result = self.resultTbl[row_idx][result_index] @@ -814,9 +851,7 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba self.onlyWeightedBaseOutput[row_idx][result_index] = onlyWeightedBaseOutput self.lastComparedWeightList[row_idx][result_index] = self.statSortSelectionList end - local slotTbl = self.slotTables[row_idx] - local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId - local slotName = jewelNodeId and "Jewel " .. tostring(jewelNodeId) or slotTbl.slotName + local slotName = self:GetReplacementSlotName(row_idx) or self.slotTables[row_idx].slotName if slotName == "Megalomaniac" then local addedNodes = {} for nodeName in (result.item_string.."\r\n"):gmatch("1 Added Passive Skill is (.-)\r?\n") do @@ -854,16 +889,19 @@ function TradeQueryClass:UpdateDropdownList(row_idx) if not self.resultTbl[row_idx] then return end - for result_index = 1, #self.resultTbl[row_idx] do - - local pb_index = self.sortedResultTbl[row_idx][result_index].index - local result = self.resultTbl[row_idx][pb_index] - local price = string.format(" %s(%d %s)", colorCodes["CURRENCY"], result.amount, result.currency) - local item = new("Item"):Item(result.item_string) - table.insert(dropdownLabels, colorCodes[item.rarity] .. item.name .. price) + -- Iterate the sorted (and potentially filtered) list so attribute-filtered rows are omitted from the dropdown + for _, sorted in ipairs(self.sortedResultTbl[row_idx] or {}) do + if sorted and sorted.index and self.resultTbl[row_idx][sorted.index] then + local result = self.resultTbl[row_idx][sorted.index] + local price = string.format(" %s(%d %s)", colorCodes["CURRENCY"], result.amount, result.currency) + local item = new("Item"):Item(result.item_string) + table.insert(dropdownLabels, colorCodes[item.rarity] .. item.name .. price) + end + end + if self.controls["resultDropdown".. row_idx] then + self.controls["resultDropdown".. row_idx].selIndex = 1 + self.controls["resultDropdown".. row_idx]:SetList(dropdownLabels) end - self.controls["resultDropdown".. row_idx].selIndex = 1 - self.controls["resultDropdown".. row_idx]:SetList(dropdownLabels) end function TradeQueryClass:ResetResultRow(rowIdx) self.itemIndexTbl[rowIdx] = nil @@ -874,6 +912,19 @@ function TradeQueryClass:ResetResultRow(rowIdx) self.controls.fullPrice.label = "^7Total Price: " .. self:GetTotalPriceString() end function TradeQueryClass:UpdateControlsWithItems(row_idx) + local results = self.resultTbl[row_idx] + if not results or #results == 0 then + self.sortedResultTbl[row_idx] = {} + if self.controls["resultDropdown".. row_idx] then + self.controls["resultDropdown".. row_idx]:SetList({}) + self.controls["resultDropdown".. row_idx].selIndex = 1 + end + self.itemIndexTbl[row_idx] = nil + self.totalPrice[row_idx] = nil + self.controls.fullPrice.label = "Total Price: " .. self:GetTotalPriceString() + return + end + local sortMode = self.itemSortSelectionList[self.pbItemSortSelectionIndex] local sortedItems, errMsg = self:SortFetchResults(row_idx, sortMode) if errMsg == "MissingConversionRates" then @@ -885,6 +936,18 @@ function TradeQueryClass:UpdateControlsWithItems(row_idx) else self:SetNotice(self.controls.pbNotice, "") end + if not sortedItems or #sortedItems == 0 then + self:SetNotice(self.controls.pbNotice, "No usable results (attribute requirements)") + self.sortedResultTbl[row_idx] = {} + if self.controls["resultDropdown".. row_idx] then + self.controls["resultDropdown".. row_idx]:SetList({}) + self.controls["resultDropdown".. row_idx].selIndex = 1 + end + self.itemIndexTbl[row_idx] = nil + self.totalPrice[row_idx] = nil + self.controls.fullPrice.label = "Total Price: " .. self:GetTotalPriceString() + return + end self.sortedResultTbl[row_idx] = sortedItems if not sortedItems[1] then @@ -917,6 +980,39 @@ end -- Method to sort the fetched results function TradeQueryClass:SortFetchResults(row_idx, mode) local calcFunc, baseOutput + local attrReqCache = {} + local slotName = self:GetReplacementSlotName(row_idx) + local results = self.resultTbl[row_idx] + if not results or #results == 0 then + return {} + end + + -- Returns true if the candidate item meets its attribute requirements when equipped + local function meetsAttributeRequirements(result_index) + if not self.hideResultsFailingAttributeRequirements or not slotName then + return true + end + if attrReqCache[result_index] ~= nil then + return attrReqCache[result_index] + end + if not calcFunc then + calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() + end + local item = new("Item"):Item(self.resultTbl[row_idx][result_index].item_string) + local output = calcFunc({ repSlotName = slotName, repItem = item }) + local ok + if output.ReqOmni then + ok = (output.ReqOmni or 0) <= (output.Omni or 0) + else + local function attrOk(reqKey, attrKey) + return (output[reqKey] or 0) <= (output[attrKey] or 0) + end + ok = attrOk("ReqStr", "Str") and attrOk("ReqDex", "Dex") and attrOk("ReqInt", "Int") + end + attrReqCache[result_index] = ok + return ok + end + local function getResultWeight(result_index) if not calcFunc then calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() @@ -945,12 +1041,16 @@ function TradeQueryClass:SortFetchResults(row_idx, mode) local newTbl = {} if mode == self.sortModes.Weight then for index, _ in pairs(self.resultTbl[row_idx]) do - t_insert(newTbl, { outputAttr = index, index = index }) + if meetsAttributeRequirements(index) then + t_insert(newTbl, { outputAttr = index, index = index }) + end end return newTbl elseif mode == self.sortModes.StatValue then for result_index = 1, #self.resultTbl[row_idx] do - t_insert(newTbl, { outputAttr = getResultWeight(result_index), index = result_index }) + if meetsAttributeRequirements(result_index) then + t_insert(newTbl, { outputAttr = getResultWeight(result_index), index = result_index }) + end end table.sort(newTbl, function(a,b) return a.outputAttr > b.outputAttr end) elseif mode == self.sortModes.StatValuePrice then @@ -970,9 +1070,11 @@ function TradeQueryClass:SortFetchResults(row_idx, mode) -- scaling factor for price local k = 0.1 - t_insert(newTbl, - { outputAttr = getResultWeight(result_index) - k * math.log(priceTable[result_index], 10), index = - result_index }) + if meetsAttributeRequirements(result_index) then + t_insert(newTbl, + { outputAttr = getResultWeight(result_index) - k * math.log(priceTable[result_index], 10), index = + result_index }) + end end table.sort(newTbl, function(a,b) return a.outputAttr > b.outputAttr end) elseif mode == self.sortModes.Price then @@ -981,7 +1083,9 @@ function TradeQueryClass:SortFetchResults(row_idx, mode) return nil, "MissingConversionRates" end for result_index, price in pairs(priceTable) do - t_insert(newTbl, { outputAttr = price, index = result_index }) + if meetsAttributeRequirements(result_index) then + t_insert(newTbl, { outputAttr = price, index = result_index }) + end end table.sort(newTbl, function(a,b) return a.outputAttr < b.outputAttr end) else @@ -1012,12 +1116,14 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro local nodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId local activeSlot = nodeId and self.itemsTab.sockets[nodeId] or slotTbl.slotName and (self.itemsTab.slots[slotTbl.slotName] or + slotTbl.slotName == "Watcher's Eye" and self:findValidSlotForWatchersEye() or -- fullName for Abyssal Sockets slotTbl.fullName and self.itemsTab.slots[slotTbl.fullName]) local function getSelectedSlot() local selectedNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId return selectedNodeId and self.itemsTab.sockets[selectedNodeId] or activeSlot end + slotTbl.replacementSlotName = activeSlot and activeSlot.slotName or slotTbl.fullName or nil local nameColor = slotTbl.unique and colorCodes.UNIQUE or "^7" controls["name" .. row_idx] = new("LabelControl"):LabelControl(top_pane_alignment_ref, { 0, row_idx * (row_height + row_vertical_padding), 135, row_height - 4 }, nameColor .. slotTbl.slotName) controls["bestButton" .. row_idx] = new("ButtonControl"):ButtonControl({ "LEFT", controls["name" .. row_idx], "LEFT" }, { 135 + 8, 0, 80, row_height }, "Find best", function() @@ -1173,8 +1279,10 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end) controls["changeButton"..row_idx].shown = function() return self.resultTbl[row_idx] end controls["resultDropdown" .. row_idx] = new("DropDownControl"):DropDownControl({ "TOPLEFT", controls["changeButton" .. row_idx], "TOPRIGHT" }, { 8, 0, 351, row_height }, {}, function(index) - self.itemIndexTbl[row_idx] = self.sortedResultTbl[row_idx][index].index - self:SetFetchResultReturn(row_idx, self.itemIndexTbl[row_idx]) + if self.sortedResultTbl[row_idx] and self.sortedResultTbl[row_idx][index] then + self.itemIndexTbl[row_idx] = self.sortedResultTbl[row_idx][index].index + self:SetFetchResultReturn(row_idx, self.itemIndexTbl[row_idx]) + end end) self:UpdateDropdownList(row_idx) local function addMegalomaniacCompareToTooltipIfApplicable(tooltip, result_index) @@ -1209,8 +1317,17 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite tooltip:AddSeparator(10) tooltip:AddLine(16, string.format("^7Price: %s %s", result.amount, result.currency)) end + local function getSelectedResult() + local selected_result_index = self.itemIndexTbl[row_idx] + local rowResults = self.resultTbl[row_idx] + return selected_result_index and rowResults and rowResults[selected_result_index], selected_result_index + end controls["importButton"..row_idx] = new("ButtonControl"):ButtonControl({ "TOPLEFT", controls["resultDropdown"..row_idx], "TOPRIGHT"}, {8, 0, 100, row_height}, "Import Item", function() - self.itemsTab:CreateDisplayItemFromRaw(self.resultTbl[row_idx][self.itemIndexTbl[row_idx]].item_string) + local itemResult = getSelectedResult() + if not itemResult or not itemResult.item_string then + return + end + self.itemsTab:CreateDisplayItemFromRaw(itemResult.item_string) local item = self.itemsTab.displayItem -- pass "true" to not auto equip it as we will have our own logic self.itemsTab:AddDisplayItem(true) @@ -1226,8 +1343,8 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end) controls["importButton"..row_idx].tooltipFunc = function(tooltip) tooltip:Clear() - local selected_result_index = self.itemIndexTbl[row_idx] - local item_string = self.resultTbl[row_idx][selected_result_index].item_string + local itemResult, selected_result_index = getSelectedResult() + local item_string = itemResult and itemResult.item_string if selected_result_index and item_string then local item = new("Item"):Item(item_string) local tooltipSlot = slotTbl.selectedJewelNodeId and self.itemsTab.sockets[slotTbl.selectedJewelNodeId] or activeSlot @@ -1236,11 +1353,13 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end end controls["importButton"..row_idx].enabled = function() - return self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]].item_string ~= nil + local itemResult = getSelectedResult() + return itemResult and itemResult.item_string ~= nil end -- Whisper so we can copy to clipboard - controls["whisperButton" .. row_idx] = new("ButtonControl"):ButtonControl({ "TOPLEFT", controls["importButton" .. row_idx], "TOPRIGHT" }, { 8, 0, 155, row_height }, function() - local itemResult = self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]] + controls["whisperButton" .. row_idx] = new("ButtonControl"):ButtonControl( + { "TOPLEFT", controls["importButton" .. row_idx], "TOPRIGHT" }, { 8, 0, 155, row_height }, function() + local itemResult = getSelectedResult() if not itemResult then return "" end @@ -1256,8 +1375,11 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end end, function() - local itemResult = self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]] - if itemResult.whisper and (itemResult.priceType ~= "~b/o") then + local itemResult = getSelectedResult() + if not itemResult then + return + end + if itemResult.whisper and (itemResult.priceType ~= "~b/o") then Copy(itemResult.whisper) else local exactQuery = dkjson.decode(self.lastQueries[row_idx]) @@ -1284,7 +1406,10 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite controls["whisperButton" .. row_idx].tooltipFunc = function(tooltip) tooltip:Clear() tooltip.center = true - local itemResult = self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]] + local itemResult = getSelectedResult() + if not itemResult then + return + end local text = itemResult.whisper and "Copies the item purchase whisper to the clipboard" or "Opens the search page to show the item" tooltip:AddLine(16, text) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 9b7e3f5b40..c8d04ac831 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -740,6 +740,16 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) -- Calculate base output with a blank item local calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() local baseItemOutput = slot and calcFunc({ repSlotName = slot.slotName, repItem = testItem }) or baseOutput + -- Determine attribute shortfall when replacing the current item with a blank base + local attrReqShortfall = { Str = 0, Dex = 0, Int = 0 } + if slot and (not slot.slotName:find("Flask")) then + local needStr = math.max(0, (baseItemOutput.ReqStr or 0) - (baseItemOutput.Str or 0)) + local needDex = math.max(0, (baseItemOutput.ReqDex or 0) - (baseItemOutput.Dex or 0)) + local needInt = math.max(0, (baseItemOutput.ReqInt or 0) - (baseItemOutput.Int or 0)) + attrReqShortfall.Str = needStr + attrReqShortfall.Dex = needDex + attrReqShortfall.Int = needInt + end -- make weights more human readable local compStatValue = TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, baseItemOutput, options.statWeights) * 1000 @@ -758,6 +768,7 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) options = options, slot = slot, requiredMods = options.requiredMods, + attrReqShortfall = attrReqShortfall, } -- OnFrame will pick this up and begin the work @@ -1032,6 +1043,23 @@ function TradeQueryGeneratorClass:FinishQuery() filters = filters + 1 end + -- If enabled, require the new item to provide enough attributes to meet build requirements + if options.includeAttrReqs and self.calcContext and self.calcContext.attrReqShortfall then + local need = self.calcContext.attrReqShortfall + if need.Str and need.Str > 0 then + t_insert(andFilters.filters, { id = "pseudo.pseudo_total_strength", value = { min = need.Str } }) + filters = filters + 1 + end + if need.Dex and need.Dex > 0 then + t_insert(andFilters.filters, { id = "pseudo.pseudo_total_dexterity", value = { min = need.Dex } }) + filters = filters + 1 + end + if need.Int and need.Int > 0 then + t_insert(andFilters.filters, { id = "pseudo.pseudo_total_intelligence", value = { min = need.Int } }) + filters = filters + 1 + end + end + if #andFilters.filters > 0 then t_insert(queryTable.query.stats, andFilters) end @@ -1293,6 +1321,12 @@ Remove: %s will be removed from the search results.]], term, term, term) controls.maxLevelLabel = new("LabelControl"):LabelControl({ "RIGHT", controls.maxLevel, "LEFT" }, { -5, 0, 0, 16 }, "^7Max Level:") updateLastAnchor(controls.maxLevel) + -- When enabled, the generated query asks for enough attributes on the new item + controls.includeAttrReqs = new("CheckBoxControl"):CheckBoxControl({"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 18}, "Attribute requirements:", function(state) end) + controls.includeAttrReqs.state = (self.lastIncludeAttrReqs == nil or self.lastIncludeAttrReqs == true) + controls.includeAttrReqs.tooltipText = "Add Str/Dex/Int pseudo filters when the current build is short on attributes.\nThis narrows the generated trade query before fetching results." + updateLastAnchor(controls.includeAttrReqs) + -- basic filtering by slot for sockets and links, Megalomaniac does not have slot and Sockets use "Jewel nodeId" if slot and not isJewelSlot and not isAbyssalJewelSlot and not slot.slotName:find("Flask") then controls.sockets = new("EditControl"):EditControl({"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 70, 18}, nil, nil, "%D") @@ -1385,6 +1419,9 @@ Remove: %s will be removed from the search results.]], term, term, term) options.maxLevel = tonumber(controls.maxLevel.buf) self.lastMaxLevel = options.maxLevel end + if controls.includeAttrReqs then + self.lastIncludeAttrReqs, options.includeAttrReqs = controls.includeAttrReqs.state, controls.includeAttrReqs.state + end if controls.sockets and controls.sockets.buf then options.sockets = tonumber(controls.sockets.buf) self.lastSockets = options.sockets From 9bdbffc4fac82c80e8fb5838c64ca8ec48a32bdf Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 28 Apr 2026 21:26:35 +0200 Subject: [PATCH 2/4] test(trade): cover attribute requirement trade filters --- spec/System/TestTradeQueryGenerator_spec.lua | 73 +++++++++++++++++ spec/System/TestTradeQuery_spec.lua | 86 ++++++++++++++++++++ 2 files changed, 159 insertions(+) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index e11ea701b9..b21fcdf00e 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -1,6 +1,58 @@ +local dkjson = require "dkjson" + describe("TradeQueryGenerator", function() local mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) + local function findStatFilter(queryTable, id) + for _, group in ipairs(queryTable.query.stats) do + for _, filter in ipairs(group.filters or {}) do + if filter.id == id then + return filter + end + end + end + end + + local function finishQueryWithAttributeShortfall(shortfall, includeAttrReqs) + local queryGen = new("TradeQueryGenerator", { itemsTab = {} }) + local queryTable + local errMsg + queryGen.modWeights = { + { tradeModId = "explicit.stat_3299347043", weight = 1, meanStatDiff = 1 }, + } + queryGen.tradeTypeIndex = 1 + queryGen.requesterContext = {} + queryGen.requesterCallback = function(_, queryJson, queryErrMsg) + queryTable = dkjson.decode(queryJson) + errMsg = queryErrMsg + end + queryGen.calcContext = { + itemCategoryQueryStr = "ring", + special = {}, + testItem = { + BuildAndParseRaw = function() end, + }, + baseOutput = { TotalDPS = 100 }, + baseStatValue = 0, + options = { + statWeights = { { stat = "TotalDPS", weightMult = 1 } }, + includeAllWEMods = false, + includeAttrReqs = includeAttrReqs, + includeMirrored = true, + influence1 = 1, + influence2 = 1, + }, + attrReqShortfall = shortfall, + } + + local previousClosePopup = main.ClosePopup + main.ClosePopup = function() end + queryGen:FinishQuery() + main.ClosePopup = previousClosePopup + + return queryTable, errMsg + end + describe("ProcessMod", function() -- Pass: Mod line maps correctly to trade stat entry without error -- Fail: Mapping fails (e.g., no match found), indicating incomplete stat parsing for curse mods, potentially missing curse-enabling items in queries @@ -192,4 +244,25 @@ describe("TradeQueryGenerator", function() assert.is_not_nil(query.filters.socket_filters.filters.links) end) end) + + describe("attribute requirement filters", function() + it("adds needed attribute pseudo filters to the generated query", function() + local queryTable, errMsg = finishQueryWithAttributeShortfall({ Str = 12, Dex = 34, Int = 56 }, true) + assert.is_nil(errMsg) + assert.are.equal(12, findStatFilter(queryTable, "pseudo.pseudo_total_strength").value.min) + assert.are.equal(34, findStatFilter(queryTable, "pseudo.pseudo_total_dexterity").value.min) + assert.are.equal(56, findStatFilter(queryTable, "pseudo.pseudo_total_intelligence").value.min) + end) + + it("omits attribute pseudo filters when disabled or no shortfall exists", function() + local disabledQuery = finishQueryWithAttributeShortfall({ Str = 12, Dex = 34, Int = 56 }, false) + local zeroQuery = finishQueryWithAttributeShortfall({ Str = 0, Dex = 0, Int = 0 }, true) + assert.is_nil(findStatFilter(disabledQuery, "pseudo.pseudo_total_strength")) + assert.is_nil(findStatFilter(disabledQuery, "pseudo.pseudo_total_dexterity")) + assert.is_nil(findStatFilter(disabledQuery, "pseudo.pseudo_total_intelligence")) + assert.is_nil(findStatFilter(zeroQuery, "pseudo.pseudo_total_strength")) + assert.is_nil(findStatFilter(zeroQuery, "pseudo.pseudo_total_dexterity")) + assert.is_nil(findStatFilter(zeroQuery, "pseudo.pseudo_total_intelligence")) + end) + end) end) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 9a83a331c4..05444a0123 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -60,6 +60,92 @@ describe("TradeQuery", function() end) assert.are.equal(0, #tooltip.lines) end) + + it("returns early from action button tooltips when filtering clears the selected result", function() + local tq = newTradeQuery({ + resultTbl = { [1] = { [1] = { item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring", amount = 1, currency = "chaos" } } }, + sortedResultTbl = { [1] = {} }, + }) + buildRow1Dropdown(tq) + local tooltip = new("Tooltip") + + assert.has_no.errors(function() + tq.controls.importButton1.tooltipFunc(tooltip) + tq.controls.whisperButton1.tooltipFunc(tooltip) + end) + assert.are.equal(0, #tooltip.lines) + end) + end) + + describe("attribute requirement result filtering", function() + local function newTradeQueryWithOutput(output, slotTbl) + local calcCalls = 0 + local tq = new("TradeQuery", { itemsTab = {} }) + tq.slotTables[1] = slotTbl or { slotName = "Ring 1" } + tq.resultTbl = { + [1] = { + [1] = { item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring", amount = 1, currency = "chaos" }, + }, + } + tq.sortModes = { + Weight = "(Highest) Weighted Sum", + } + tq.itemsTab.build = { + calcsTab = { + GetMiscCalculator = function() + return function() + calcCalls = calcCalls + 1 + return output + end, {} + end, + }, + } + tq.itemsTab.slots = { + ["Ring 1"] = {}, + } + return tq, function() + return calcCalls + end + end + + it("filters fetched results that do not meet attribute requirements", function() + local tq = newTradeQueryWithOutput({ ReqStr = 50, Str = 40, ReqDex = 0, Dex = 0, ReqInt = 0, Int = 0 }) + tq.hideResultsFailingAttributeRequirements = true + local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) + assert.are.equal(0, #sortedItems) + end) + + it("keeps fetched results that meet attribute requirements", function() + local tq = newTradeQueryWithOutput({ ReqStr = 50, Str = 60, ReqDex = 30, Dex = 30, ReqInt = 20, Int = 25 }) + tq.hideResultsFailingAttributeRequirements = true + local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) + assert.are.equal(1, #sortedItems) + assert.are.equal(1, sortedItems[1].index) + end) + + it("filters fetched results that do not meet Omniscience requirements", function() + local tq = newTradeQueryWithOutput({ ReqOmni = 100, Omni = 80 }) + tq.hideResultsFailingAttributeRequirements = true + local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) + assert.are.equal(0, #sortedItems) + end) + + it("keeps fetched results without recalculating by default", function() + local tq, calcCalls = newTradeQueryWithOutput({ ReqStr = 50, Str = 40, ReqDex = 0, Dex = 0, ReqInt = 0, Int = 0 }) + local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) + assert.are.equal(1, #sortedItems) + assert.are.equal(1, sortedItems[1].index) + assert.are.equal(0, calcCalls()) + end) + + it("does not apply equipment attribute filtering to rows without a replacement slot", function() + local tq, calcCalls = newTradeQueryWithOutput({ ReqStr = 50, Str = 40, ReqDex = 0, Dex = 0, ReqInt = 0, Int = 0 }, { slotName = "Megalomaniac", unique = true }) + tq.hideResultsFailingAttributeRequirements = true + local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) + assert.are.equal(1, #sortedItems) + assert.are.equal(1, sortedItems[1].index) + assert.are.equal(0, calcCalls()) + end) end) describe("ReduceOutput", function() it("preserves lower-is-better values for weighted result comparison", function() From 28bf67dd46e917e32bb786172594139e1f6864f7 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 15 Aug 2026 17:11:31 +0200 Subject: [PATCH 3/4] Adapt trade requirement tests to current class syntax --- spec/System/TestTradeQueryGenerator_spec.lua | 2 +- spec/System/TestTradeQuery_spec.lua | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index b21fcdf00e..efe430a576 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -14,7 +14,7 @@ describe("TradeQueryGenerator", function() end local function finishQueryWithAttributeShortfall(shortfall, includeAttrReqs) - local queryGen = new("TradeQueryGenerator", { itemsTab = {} }) + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) local queryTable local errMsg queryGen.modWeights = { diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 05444a0123..4b4ebbb09b 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -67,7 +67,7 @@ describe("TradeQuery", function() sortedResultTbl = { [1] = {} }, }) buildRow1Dropdown(tq) - local tooltip = new("Tooltip") + local tooltip = new("Tooltip"):Tooltip() assert.has_no.errors(function() tq.controls.importButton1.tooltipFunc(tooltip) @@ -80,7 +80,7 @@ describe("TradeQuery", function() describe("attribute requirement result filtering", function() local function newTradeQueryWithOutput(output, slotTbl) local calcCalls = 0 - local tq = new("TradeQuery", { itemsTab = {} }) + local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) tq.slotTables[1] = slotTbl or { slotName = "Ring 1" } tq.resultTbl = { [1] = { From 80f3ac76ce6ef7f7abd49eaf54a508a8841d365c Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 12:50:45 +0200 Subject: [PATCH 4/4] Fix and simplify trade attribute requirement filtering Remove the obsolete Watcher's Eye fallback, resolve replacement slots from live item state, and share requirement semantics across query generation and result validation. --- spec/System/TestTradeQueryGenerator_spec.lua | 61 ++++++++++++-- spec/System/TestTradeQuery_spec.lua | 45 ++++++++-- src/Classes/TradeHelpers.lua | 27 +++++- src/Classes/TradeQuery.lua | 88 ++++++++------------ src/Classes/TradeQueryGenerator.lua | 30 +++---- 5 files changed, 168 insertions(+), 83 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index efe430a576..8fd79ced69 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -13,7 +13,7 @@ describe("TradeQueryGenerator", function() end end - local function finishQueryWithAttributeShortfall(shortfall, includeAttrReqs) + local function finishQueryWithAttributeShortfall(shortfall, includeAttributeRequirementFilters) local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) local queryTable local errMsg @@ -27,7 +27,7 @@ describe("TradeQueryGenerator", function() errMsg = queryErrMsg end queryGen.calcContext = { - itemCategoryQueryStr = "ring", + itemCategoryQueryStr = "accessory.ring", special = {}, testItem = { BuildAndParseRaw = function() end, @@ -37,22 +37,58 @@ describe("TradeQueryGenerator", function() options = { statWeights = { { stat = "TotalDPS", weightMult = 1 } }, includeAllWEMods = false, - includeAttrReqs = includeAttrReqs, + includeAttributeRequirementFilters = includeAttributeRequirementFilters, includeMirrored = true, influence1 = 1, influence2 = 1, }, - attrReqShortfall = shortfall, + attributeRequirementShortfall = shortfall, } local previousClosePopup = main.ClosePopup main.ClosePopup = function() end - queryGen:FinishQuery() + local ok, finishError = pcall(function() + queryGen:FinishQuery() + end) main.ClosePopup = previousClosePopup + assert.is_true(ok, finishError) return queryTable, errMsg end + local function startQueryWithReplacementOutput(replacementOutput) + local calcArgs + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ + itemsTab = { + items = { + [1] = { baseName = "Gold Ring", type = "Ring", base = { type = "Ring" } }, + }, + build = { + calcsTab = { + GetMiscCalculator = function() + return function(args) + calcArgs = args + return replacementOutput + end, { TotalDPS = 100 } + end, + }, + }, + }, + }) + local previousOpenPopup = main.OpenPopup + main.OpenPopup = function() return {} end + local ok, startError = pcall(function() + queryGen:StartQuery({ slotName = "Ring 1", selItemId = 1 }, { + influence1 = 1, + influence2 = 1, + statWeights = { { stat = "TotalDPS", weightMult = 1 } }, + }) + end) + main.OpenPopup = previousOpenPopup + assert.is_true(ok, startError) + return queryGen, calcArgs + end + describe("ProcessMod", function() -- Pass: Mod line maps correctly to trade stat entry without error -- Fail: Mapping fails (e.g., no match found), indicating incomplete stat parsing for curse mods, potentially missing curse-enabling items in queries @@ -246,6 +282,21 @@ describe("TradeQueryGenerator", function() end) describe("attribute requirement filters", function() + it("calculates the shortfall from the blank replacement output", function() + local queryGen, calcArgs = startQueryWithReplacementOutput({ + TotalDPS = 100, + ReqStr = 50, + Str = 40, + ReqDex = 30, + Dex = 35, + ReqInt = 25, + Int = 20, + }) + assert.are.equal("Ring 1", calcArgs.repSlotName) + assert.are.equal("Gold Ring", calcArgs.repItem.baseName) + assert.same({ Str = 10, Dex = 0, Int = 5 }, queryGen.calcContext.attributeRequirementShortfall) + end) + it("adds needed attribute pseudo filters to the generated query", function() local queryTable, errMsg = finishQueryWithAttributeShortfall({ Str = 12, Dex = 34, Int = 56 }, true) assert.is_nil(errMsg) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 4b4ebbb09b..a128a752c4 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -9,7 +9,7 @@ describe("TradeQuery", function() describe("result dropdown tooltipFunc", function() -- Builds a TradeQuery with the strict minimum needed for -- PriceItemRowDisplay to construct row 1 without exploding. Only the - -- two itemsTab subtables read by the slot lookup at the top of + -- three itemsTab fields read by the slot lookup at the top of -- PriceItemRowDisplay need to be created here; everything else either -- lives behind a callback we never trigger, or is already initialized -- by the TradeQuery constructor. @@ -17,6 +17,7 @@ describe("TradeQuery", function() local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) tq.itemsTab.activeItemSet = {} tq.itemsTab.slots = {} + tq.itemsTab.sockets = {} tq.slotTables[1] = { slotName = "Ring 1" } if state.resultTbl then tq.resultTbl = state.resultTbl end if state.sortedResultTbl then tq.sortedResultTbl = state.sortedResultTbl end @@ -30,6 +31,15 @@ describe("TradeQuery", function() return tq.controls.resultDropdown1 end + it("constructs the Watcher's Eye row without an active jewel socket", function() + local tq = newTradeQuery({}) + tq.slotTables[1] = { slotName = "Watcher's Eye", unique = true } + + assert.has_no.errors(function() + buildRow1Dropdown(tq) + end) + end) + it("returns early when sortedResultTbl[row_idx] is missing", function() -- No sorted results at all -> first guard must short-circuit. local tq = newTradeQuery({}) @@ -76,10 +86,32 @@ describe("TradeQuery", function() assert.are.equal(0, #tooltip.lines) end) end) + describe("replacement slot resolution", function() + it("resolves normal, Abyssal, and selected jewel slots without stored row state", function() + local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tq.itemsTab.slots = { + ["Ring 1"] = {}, + ["Body Armour Abyssal Socket 1"] = {}, + } + tq.itemsTab.sockets = { [123] = {} } + tq.slotTables = { + { slotName = "Ring 1" }, + { slotName = "Abyssal Socket 1", fullName = "Body Armour Abyssal Socket 1" }, + { slotName = "Jewel Socket", selectedJewelNodeId = 123 }, + { slotName = "Watcher's Eye", unique = true }, + } + + assert.are.equal("Ring 1", tq:GetReplacementSlotName(1)) + assert.are.equal("Body Armour Abyssal Socket 1", tq:GetReplacementSlotName(2)) + assert.are.equal("Jewel 123", tq:GetReplacementSlotName(3)) + assert.is_nil(tq:GetReplacementSlotName(4)) + end) + end) describe("attribute requirement result filtering", function() local function newTradeQueryWithOutput(output, slotTbl) local calcCalls = 0 + local lastCalcArgs local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) tq.slotTables[1] = slotTbl or { slotName = "Ring 1" } tq.resultTbl = { @@ -93,8 +125,9 @@ describe("TradeQuery", function() tq.itemsTab.build = { calcsTab = { GetMiscCalculator = function() - return function() + return function(calcArgs) calcCalls = calcCalls + 1 + lastCalcArgs = calcArgs return output end, {} end, @@ -103,16 +136,16 @@ describe("TradeQuery", function() tq.itemsTab.slots = { ["Ring 1"] = {}, } - return tq, function() - return calcCalls - end + return tq, function() return calcCalls end, function() return lastCalcArgs end end it("filters fetched results that do not meet attribute requirements", function() - local tq = newTradeQueryWithOutput({ ReqStr = 50, Str = 40, ReqDex = 0, Dex = 0, ReqInt = 0, Int = 0 }) + local tq, _, calcArgs = newTradeQueryWithOutput({ ReqStr = 50, Str = 40, ReqDex = 0, Dex = 0, ReqInt = 0, Int = 0 }) tq.hideResultsFailingAttributeRequirements = true local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) assert.are.equal(0, #sortedItems) + assert.are.equal("Ring 1", calcArgs().repSlotName) + assert.are.equal("Behemoth Hold, Gold Ring", calcArgs().repItem.name) end) it("keeps fetched results that meet attribute requirements", function() diff --git a/src/Classes/TradeHelpers.lua b/src/Classes/TradeHelpers.lua index 4448bd0a03..395658efcc 100644 --- a/src/Classes/TradeHelpers.lua +++ b/src/Classes/TradeHelpers.lua @@ -1,9 +1,10 @@ -- Path of Building -- -- Module: Compare Trade Helpers --- Stateless trade mod lookup/matching and item display helper functions +-- Stateless trade matching, attribute requirement, and item display helpers -- local m_floor = math.floor +local m_max = math.max local statDescData = require("Data.StatDescriptions.stat_descriptions") -- precalculate patterns used for matching stat lines @@ -36,6 +37,30 @@ end local M = {} +local attributeOutputKeys = { "Str", "Dex", "Int" } + +-- Shared by pre-fetch query constraints and post-fetch candidate validation. +-- Omniscience replaces the individual attribute requirements when it is active. +function M.getAttributeRequirementShortfall(output) + if (output.ReqOmni or 0) > 0 then + return { Omni = m_max(0, output.ReqOmni - (output.Omni or 0)) } + end + local shortfall = {} + for _, attributeKey in ipairs(attributeOutputKeys) do + shortfall[attributeKey] = m_max(0, (output["Req" .. attributeKey] or 0) - (output[attributeKey] or 0)) + end + return shortfall +end + +function M.meetsAttributeRequirements(output) + for _, missingAmount in pairs(M.getAttributeRequirementShortfall(output)) do + if missingAmount > 0 then + return false + end + end + return true +end + -- Helper: get rarity color code for an item --- @param item table function M.getRarityColor(item) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index ce37e5b3d5..d746ac6d84 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -7,6 +7,7 @@ local dkjson = require "dkjson" local itemSlotHelper = LoadModule("Modules/ItemSlotHelper") +local tradeHelpers = LoadModule("Classes/TradeHelpers") local get_time = os.time local t_insert = table.insert @@ -458,19 +459,19 @@ Highest Weight - Displays the order retrieved from trade]] self.controls.itemSortSelection:SetSel(self.pbItemSortSelectionIndex, true) self.controls.itemSortSelectionLabel = new("LabelControl"):LabelControl({"TOPRIGHT", self.controls.itemSortSelection, "TOPLEFT"}, {-4, 0, 56, 16}, "^7Sort By:") - -- Hide fetched results that would leave unmet attribute requirements unless unchecked. + -- Optional post-fetch validation complements the query filters with the fully equipped output. local hideAttributeRequirementsLabel = "^7Hide results failing attribute requirements" local hideAttributeRequirementsLabelWidth = DrawStringWidth(row_height - 4, "VAR", hideAttributeRequirementsLabel) + 5 local hideAttributeRequirementsRect = {24 + hideAttributeRequirementsLabelWidth, 0, row_height, row_height} - self.controls.hideAttributeRequirementsCheck = new("CheckBoxControl"):CheckBoxControl({"LEFT", self.controls.tradeTypeSelection, "RIGHT"}, hideAttributeRequirementsRect, hideAttributeRequirementsLabel, function(state) + self.controls.hideResultsFailingAttributeRequirementsCheck = new("CheckBoxControl"):CheckBoxControl({"LEFT", self.controls.tradeTypeSelection, "RIGHT"}, hideAttributeRequirementsRect, hideAttributeRequirementsLabel, function(state) self.hideResultsFailingAttributeRequirements = state for row_idx, _ in pairs(self.resultTbl) do self:UpdateControlsWithItems(row_idx) end end) - self.controls.hideAttributeRequirementsCheck.tooltipText = "Hide fetched results when equipping the item would leave unmet Str/Dex/Int/Omniscience attribute requirements.\nUnchecked: show those results after fetching." + self.controls.hideResultsFailingAttributeRequirementsCheck.tooltipText = "Hide fetched results when equipping the item would leave unmet Str/Dex/Int/Omniscience attribute requirements.\nUnchecked: show those results after fetching." self.hideResultsFailingAttributeRequirements = self.hideResultsFailingAttributeRequirements == true - self.controls.hideAttributeRequirementsCheck.state = self.hideResultsFailingAttributeRequirements + self.controls.hideResultsFailingAttributeRequirementsCheck.state = self.hideResultsFailingAttributeRequirements -- Realm selection self.controls.realmLabel = new("LabelControl"):LabelControl({"LEFT", self.controls.setSelect, "RIGHT"}, {18, 0, 20, row_height - 4}, "^7Realm:") @@ -812,24 +813,29 @@ function TradeQueryClass:ReduceOutput(output) return smallOutput end -function TradeQueryClass:GetReplacementSlotName(row_idx) +-- Resolve from current ItemsTab state so result filtering does not depend on row-control construction. +function TradeQueryClass:GetReplacementSlot(row_idx) local slotTbl = self.slotTables[row_idx] if not slotTbl then return nil end local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId if jewelNodeId then - return "Jewel " .. tostring(jewelNodeId) - end - if slotTbl.replacementSlotName then - return slotTbl.replacementSlotName + return self.itemsTab.sockets and self.itemsTab.sockets[jewelNodeId] end if slotTbl.fullName then - return slotTbl.fullName + return self.itemsTab.slots and self.itemsTab.slots[slotTbl.fullName] end - if self.itemsTab.slots and self.itemsTab.slots[slotTbl.slotName] then - return slotTbl.slotName + return self.itemsTab.slots and self.itemsTab.slots[slotTbl.slotName] +end + +function TradeQueryClass:GetReplacementSlotName(row_idx) + local slotTbl = self.slotTables[row_idx] + if not slotTbl or not self:GetReplacementSlot(row_idx) then + return nil end + local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId + return jewelNodeId and "Jewel " .. tostring(jewelNodeId) or slotTbl.fullName or slotTbl.slotName end -- Method to evaluate a result by getting it's output and weight @@ -912,16 +918,20 @@ function TradeQueryClass:ResetResultRow(rowIdx) self.controls.fullPrice.label = "^7Total Price: " .. self:GetTotalPriceString() end function TradeQueryClass:UpdateControlsWithItems(row_idx) - local results = self.resultTbl[row_idx] - if not results or #results == 0 then + local function clearVisibleResults() self.sortedResultTbl[row_idx] = {} + self.itemIndexTbl[row_idx] = nil + self.totalPrice[row_idx] = nil if self.controls["resultDropdown".. row_idx] then self.controls["resultDropdown".. row_idx]:SetList({}) self.controls["resultDropdown".. row_idx].selIndex = 1 end - self.itemIndexTbl[row_idx] = nil - self.totalPrice[row_idx] = nil - self.controls.fullPrice.label = "Total Price: " .. self:GetTotalPriceString() + self.controls.fullPrice.label = "^7Total Price: " .. self:GetTotalPriceString() + end + + local results = self.resultTbl[row_idx] + if not results or #results == 0 then + clearVisibleResults() return end @@ -937,24 +947,13 @@ function TradeQueryClass:UpdateControlsWithItems(row_idx) self:SetNotice(self.controls.pbNotice, "") end if not sortedItems or #sortedItems == 0 then - self:SetNotice(self.controls.pbNotice, "No usable results (attribute requirements)") - self.sortedResultTbl[row_idx] = {} - if self.controls["resultDropdown".. row_idx] then - self.controls["resultDropdown".. row_idx]:SetList({}) - self.controls["resultDropdown".. row_idx].selIndex = 1 - end - self.itemIndexTbl[row_idx] = nil - self.totalPrice[row_idx] = nil - self.controls.fullPrice.label = "Total Price: " .. self:GetTotalPriceString() + clearVisibleResults() + self:SetNotice(self.controls.pbNotice, self.hideResultsFailingAttributeRequirements and + "No usable results (attribute requirements)" or "^4No compatible items found for this slot.") return end self.sortedResultTbl[row_idx] = sortedItems - if not sortedItems[1] then - self:ResetResultRow(row_idx) - self:SetNotice(self.controls.pbNotice, "^4No compatible items found for this slot.") - return - end local pb_index = sortedItems[1].index self.itemIndexTbl[row_idx] = pb_index self.controls["priceButton".. row_idx].tooltipText = "Sorted by " .. self.itemSortSelectionList[self.pbItemSortSelectionIndex] @@ -980,37 +979,24 @@ end -- Method to sort the fetched results function TradeQueryClass:SortFetchResults(row_idx, mode) local calcFunc, baseOutput - local attrReqCache = {} local slotName = self:GetReplacementSlotName(row_idx) local results = self.resultTbl[row_idx] if not results or #results == 0 then return {} end - -- Returns true if the candidate item meets its attribute requirements when equipped + -- Post-fetch validation recalculates the equipped candidate so Omniscience and + -- other requirement transformations are applied before filtering the result. local function meetsAttributeRequirements(result_index) if not self.hideResultsFailingAttributeRequirements or not slotName then return true end - if attrReqCache[result_index] ~= nil then - return attrReqCache[result_index] - end if not calcFunc then calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() end local item = new("Item"):Item(self.resultTbl[row_idx][result_index].item_string) local output = calcFunc({ repSlotName = slotName, repItem = item }) - local ok - if output.ReqOmni then - ok = (output.ReqOmni or 0) <= (output.Omni or 0) - else - local function attrOk(reqKey, attrKey) - return (output[reqKey] or 0) <= (output[attrKey] or 0) - end - ok = attrOk("ReqStr", "Str") and attrOk("ReqDex", "Dex") and attrOk("ReqInt", "Int") - end - attrReqCache[result_index] = ok - return ok + return tradeHelpers.meetsAttributeRequirements(output) end local function getResultWeight(result_index) @@ -1113,17 +1099,11 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro local controls = self.controls local slotTbl = self.slotTables[row_idx] local activeSlotRef = slotTbl.nodeId and self.itemsTab.activeItemSet[slotTbl.nodeId] or self.itemsTab.activeItemSet[slotTbl.slotName] - local nodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId - local activeSlot = nodeId and self.itemsTab.sockets[nodeId] or - slotTbl.slotName and (self.itemsTab.slots[slotTbl.slotName] or - slotTbl.slotName == "Watcher's Eye" and self:findValidSlotForWatchersEye() or - -- fullName for Abyssal Sockets - slotTbl.fullName and self.itemsTab.slots[slotTbl.fullName]) + local activeSlot = self:GetReplacementSlot(row_idx) local function getSelectedSlot() local selectedNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId return selectedNodeId and self.itemsTab.sockets[selectedNodeId] or activeSlot end - slotTbl.replacementSlotName = activeSlot and activeSlot.slotName or slotTbl.fullName or nil local nameColor = slotTbl.unique and colorCodes.UNIQUE or "^7" controls["name" .. row_idx] = new("LabelControl"):LabelControl(top_pane_alignment_ref, { 0, row_idx * (row_height + row_vertical_padding), 135, row_height - 4 }, nameColor .. slotTbl.slotName) controls["bestButton" .. row_idx] = new("ButtonControl"):ButtonControl({ "LEFT", controls["name" .. row_idx], "LEFT" }, { 135 + 8, 0, 80, row_height }, "Find best", function() diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index c8d04ac831..ae80c098eb 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -740,15 +740,10 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) -- Calculate base output with a blank item local calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() local baseItemOutput = slot and calcFunc({ repSlotName = slot.slotName, repItem = testItem }) or baseOutput - -- Determine attribute shortfall when replacing the current item with a blank base - local attrReqShortfall = { Str = 0, Dex = 0, Int = 0 } + -- Pre-fetch constraint: require the replacement to restore attributes lost with the current item. + local attributeRequirementShortfall = { Str = 0, Dex = 0, Int = 0 } if slot and (not slot.slotName:find("Flask")) then - local needStr = math.max(0, (baseItemOutput.ReqStr or 0) - (baseItemOutput.Str or 0)) - local needDex = math.max(0, (baseItemOutput.ReqDex or 0) - (baseItemOutput.Dex or 0)) - local needInt = math.max(0, (baseItemOutput.ReqInt or 0) - (baseItemOutput.Int or 0)) - attrReqShortfall.Str = needStr - attrReqShortfall.Dex = needDex - attrReqShortfall.Int = needInt + attributeRequirementShortfall = tradeHelpers.getAttributeRequirementShortfall(baseItemOutput) end -- make weights more human readable local compStatValue = TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, baseItemOutput, options.statWeights) * 1000 @@ -768,7 +763,7 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) options = options, slot = slot, requiredMods = options.requiredMods, - attrReqShortfall = attrReqShortfall, + attributeRequirementShortfall = attributeRequirementShortfall, } -- OnFrame will pick this up and begin the work @@ -1044,8 +1039,8 @@ function TradeQueryGeneratorClass:FinishQuery() end -- If enabled, require the new item to provide enough attributes to meet build requirements - if options.includeAttrReqs and self.calcContext and self.calcContext.attrReqShortfall then - local need = self.calcContext.attrReqShortfall + if options.includeAttributeRequirementFilters and self.calcContext and self.calcContext.attributeRequirementShortfall then + local need = self.calcContext.attributeRequirementShortfall if need.Str and need.Str > 0 then t_insert(andFilters.filters, { id = "pseudo.pseudo_total_strength", value = { min = need.Str } }) filters = filters + 1 @@ -1322,10 +1317,10 @@ Remove: %s will be removed from the search results.]], term, term, term) updateLastAnchor(controls.maxLevel) -- When enabled, the generated query asks for enough attributes on the new item - controls.includeAttrReqs = new("CheckBoxControl"):CheckBoxControl({"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 18}, "Attribute requirements:", function(state) end) - controls.includeAttrReqs.state = (self.lastIncludeAttrReqs == nil or self.lastIncludeAttrReqs == true) - controls.includeAttrReqs.tooltipText = "Add Str/Dex/Int pseudo filters when the current build is short on attributes.\nThis narrows the generated trade query before fetching results." - updateLastAnchor(controls.includeAttrReqs) + controls.includeAttributeRequirementFilters = new("CheckBoxControl"):CheckBoxControl({"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 18}, "Attribute requirements:", function(state) end) + controls.includeAttributeRequirementFilters.state = self.lastIncludeAttributeRequirementFilters == nil or self.lastIncludeAttributeRequirementFilters == true + controls.includeAttributeRequirementFilters.tooltipText = "Add Str/Dex/Int pseudo filters when the current build is short on attributes.\nThis narrows the generated trade query before fetching results." + updateLastAnchor(controls.includeAttributeRequirementFilters) -- basic filtering by slot for sockets and links, Megalomaniac does not have slot and Sockets use "Jewel nodeId" if slot and not isJewelSlot and not isAbyssalJewelSlot and not slot.slotName:find("Flask") then @@ -1419,8 +1414,9 @@ Remove: %s will be removed from the search results.]], term, term, term) options.maxLevel = tonumber(controls.maxLevel.buf) self.lastMaxLevel = options.maxLevel end - if controls.includeAttrReqs then - self.lastIncludeAttrReqs, options.includeAttrReqs = controls.includeAttrReqs.state, controls.includeAttrReqs.state + if controls.includeAttributeRequirementFilters then + self.lastIncludeAttributeRequirementFilters = controls.includeAttributeRequirementFilters.state + options.includeAttributeRequirementFilters = controls.includeAttributeRequirementFilters.state end if controls.sockets and controls.sockets.buf then options.sockets = tonumber(controls.sockets.buf)