From e49659a5f7ba9f810e609c70b9b77b4a5f0439e4 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 17 Apr 2026 00:52:06 +0200 Subject: [PATCH 1/8] 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 | 208 ++++++++++++++++++++++------ src/Classes/TradeQueryGenerator.lua | 41 +++++- 2 files changed, 206 insertions(+), 43 deletions(-) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 66ad1fc1ff9..4bece938544 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -40,6 +40,7 @@ function TradeQueryClass:TradeQuery(itemsTab) ---@type TradeQuerySlotTable[] 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 = {} @@ -459,6 +460,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) @@ -570,7 +585,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 @@ -655,7 +672,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) @@ -771,7 +788,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) @@ -817,6 +834,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] @@ -837,8 +874,17 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba self.lastComparedWeightList[row_idx][result_index] = self.statSortSelectionList end local slotTbl = self.slotTables[row_idx] - local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId - if slotTbl.slotName == "Megalomaniac" then + if slotTbl.slotName == "Pearl of Tsoatha" and not slotTbl.selectedSlotName then + for index = 1, 3 do + local ringSlot = self.itemsTab.slots["Ring " .. index] + if ringSlot and ringSlot.shown() then + slotTbl.selectedSlotName = ringSlot.slotName + break + end + end + end + local slotName = self:GetReplacementSlotName(row_idx) or slotTbl.selectedSlotName or slotTbl.slotName + if slotName == "Megalomaniac" then local addedNodes = {} for nodeName in (result.item_string.."\r\n"):gmatch("1 Added Passive Skill is (.-)\r?\n") do t_insert(addedNodes, self.itemsTab.build.spec.tree.clusterNodeMap[nodeName]) @@ -860,16 +906,6 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba } table.sort(result.evaluation, function(a, b) return a.weight > b.weight end) else - if slotTbl.slotName == "Pearl of Tsoatha" and not slotTbl.selectedSlotName then - for index = 1, 3 do - local ringSlot = self.itemsTab.slots["Ring " .. index] - if ringSlot and ringSlot.shown() then - slotTbl.selectedSlotName = ringSlot.slotName - break - end - end - end - local slotName = jewelNodeId and "Jewel " .. tostring(jewelNodeId) or slotTbl.selectedSlotName or slotTbl.slotName local item = new("Item"):Item(result.item_string) local output = self:ReduceOutput(calcFunc({ repSlotName = slotName, repItem = item })) @@ -885,16 +921,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 @@ -905,6 +944,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 @@ -916,6 +968,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 @@ -948,6 +1012,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() @@ -976,12 +1073,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 @@ -1001,9 +1102,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 @@ -1012,7 +1115,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 @@ -1043,12 +1148,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() @@ -1209,8 +1316,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) @@ -1245,8 +1354,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) @@ -1262,8 +1380,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 @@ -1272,11 +1390,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 @@ -1292,8 +1412,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]) @@ -1320,7 +1443,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 826cb34a086..a36ab358009 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -766,6 +766,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 @@ -784,7 +794,8 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) options = options, slot = slot, requiredMods = options.requiredMods, - blockedMods = options.blockedMods + blockedMods = options.blockedMods, + attrReqShortfall = attrReqShortfall, } -- OnFrame will pick this up and begin the work @@ -1061,6 +1072,24 @@ function TradeQueryGeneratorClass:FinishQuery() queryTable.query[k] = v end + local options = self.calcContext.options + -- If enabled, require the new item to provide enough attributes to meet build requirements. + if options.includeAttrReqs and self.calcContext.attrReqShortfall then + local need = self.calcContext.attrReqShortfall + if need.Str and need.Str > 0 then + complexityBudget = complexityBudget - 4 + t_insert(andGroup.filters, { id = "pseudo.pseudo_total_strength", value = { min = need.Str } }) + end + if need.Dex and need.Dex > 0 then + complexityBudget = complexityBudget - 4 + t_insert(andGroup.filters, { id = "pseudo.pseudo_total_dexterity", value = { min = need.Dex } }) + end + if need.Int and need.Int > 0 then + complexityBudget = complexityBudget - 4 + t_insert(andGroup.filters, { id = "pseudo.pseudo_total_intelligence", value = { min = need.Int } }) + end + end + -- and filters specified by the user for _, entry in ipairs(requiredMods) do complexityBudget = complexityBudget - 4 @@ -1070,7 +1099,6 @@ function TradeQueryGeneratorClass:FinishQuery() complexityBudget = complexityBudget - 4 t_insert(notGroup.filters, { id = entry.tradeId, value = { min = entry.value } }) end - local options = self.calcContext.options if not options.includeMirrored then complexityBudget = complexityBudget - 3 queryTable.query.filters.misc_filters = { @@ -1324,6 +1352,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") @@ -1407,6 +1441,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 2916df64928dda94000b119977876e214a0e8a93 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 28 Apr 2026 21:26:35 +0200 Subject: [PATCH 2/8] 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 ff8b269fc0d..de97163e66c 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 @@ -204,4 +256,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 c700a332cf8..1d62a9dac35 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("GetResultEvaluation", function() it("uses the first visible ring for a Pearl result without a selected slot", function() From 680cbeaa3ea96d06a6d1b5158bcccf1d7122a496 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 15 Aug 2026 17:11:31 +0200 Subject: [PATCH 3/8] 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 de97163e66c..aa90b3befa6 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 1d62a9dac35..1753d4d611f 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 f69c7883813f41aa2c47fc6f8b9bd18cfb821819 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 12:50:45 +0200 Subject: [PATCH 4/8] 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 | 46 +++++++- src/Classes/TradeHelpers.lua | 27 ++++- src/Classes/TradeQuery.lua | 111 ++++++++----------- src/Classes/TradeQueryGenerator.lua | 30 +++-- 5 files changed, 182 insertions(+), 93 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index aa90b3befa6..8ef0094a9f0 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 @@ -258,6 +294,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 1753d4d611f..7f5aba34dfb 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() @@ -176,6 +209,7 @@ describe("TradeQuery", function() slotName = "Megalomaniac", unique = true, alreadyCorrupted = true, selectedJewelNodeId = 12345, } local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tq.itemsTab.sockets = { [12345] = {} } tq.statSortSelectionList = {} tq.tradeQueryGenerator = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) tq.itemsTab.build = { diff --git a/src/Classes/TradeHelpers.lua b/src/Classes/TradeHelpers.lua index 444834a7974..87c1b7f4b75 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 -- The stat description data is big and is only needed once a trade lookup actually runs, -- so it and its precalculated patterns are built lazily @@ -66,6 +67,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 4bece938544..a885305367a 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -7,6 +7,7 @@ local dkjson = require "dkjson" local itemSlotHelper = require("Modules.ItemSlotHelper") +local tradeHelpers = require("Classes.TradeHelpers") local get_time = os.time local t_insert = table.insert @@ -460,19 +461,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:") @@ -834,24 +835,41 @@ 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) + return self.itemsTab.sockets and self.itemsTab.sockets[jewelNodeId] + end + if slotTbl.slotName == "Pearl of Tsoatha" and not slotTbl.selectedSlotName then + for index = 1, 3 do + local ringSlot = self.itemsTab.slots and self.itemsTab.slots["Ring " .. index] + if ringSlot and ringSlot.shown() then + slotTbl.selectedSlotName = ringSlot.slotName + break + end + end end - if slotTbl.replacementSlotName then - return slotTbl.replacementSlotName + if slotTbl.selectedSlotName then + return self.itemsTab.slots and self.itemsTab.slots[slotTbl.selectedSlotName] 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.selectedSlotName or slotTbl.fullName or slotTbl.slotName end -- Method to evaluate a result by getting it's output and weight @@ -874,17 +892,8 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba self.lastComparedWeightList[row_idx][result_index] = self.statSortSelectionList end local slotTbl = self.slotTables[row_idx] - if slotTbl.slotName == "Pearl of Tsoatha" and not slotTbl.selectedSlotName then - for index = 1, 3 do - local ringSlot = self.itemsTab.slots["Ring " .. index] - if ringSlot and ringSlot.shown() then - slotTbl.selectedSlotName = ringSlot.slotName - break - end - end - end - local slotName = self:GetReplacementSlotName(row_idx) or slotTbl.selectedSlotName or slotTbl.slotName - if slotName == "Megalomaniac" then + local slotName = self:GetReplacementSlotName(row_idx) or slotTbl.slotName + if slotTbl.slotName == "Megalomaniac" then local addedNodes = {} for nodeName in (result.item_string.."\r\n"):gmatch("1 Added Passive Skill is (.-)\r?\n") do t_insert(addedNodes, self.itemsTab.build.spec.tree.clusterNodeMap[nodeName]) @@ -944,16 +953,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 @@ -969,24 +982,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] @@ -1012,37 +1014,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) @@ -1145,17 +1134,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 a36ab358009..8fcfacbcda4 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -766,15 +766,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 @@ -795,7 +790,7 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) slot = slot, requiredMods = options.requiredMods, blockedMods = options.blockedMods, - attrReqShortfall = attrReqShortfall, + attributeRequirementShortfall = attributeRequirementShortfall, } -- OnFrame will pick this up and begin the work @@ -1074,8 +1069,8 @@ function TradeQueryGeneratorClass:FinishQuery() local options = self.calcContext.options -- If enabled, require the new item to provide enough attributes to meet build requirements. - if options.includeAttrReqs and self.calcContext.attrReqShortfall then - local need = self.calcContext.attrReqShortfall + if options.includeAttributeRequirementFilters and self.calcContext.attributeRequirementShortfall then + local need = self.calcContext.attributeRequirementShortfall if need.Str and need.Str > 0 then complexityBudget = complexityBudget - 4 t_insert(andGroup.filters, { id = "pseudo.pseudo_total_strength", value = { min = need.Str } }) @@ -1353,10 +1348,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 @@ -1441,8 +1436,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) From e37d51c49999c7213d2de692f00f3a132516385d Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 9 Oct 2026 12:53:19 +0200 Subject: [PATCH 5/8] Test trade requirement filtering across sort modes Check mixed eligible and ineligible results in every sort mode while preserving the fetched result index. Construct the empty filtered row through UpdateControlsWithItems and verify its controls and total price. --- spec/System/TestTradeQuery_spec.lua | 135 +++++++++++++++------------- 1 file changed, 75 insertions(+), 60 deletions(-) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 7f5aba34dfb..3f65fda9ced 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -2,35 +2,24 @@ describe("TradeQuery", function() local mock_tradeQuery local mock_queryGen + local function newTradeQuery(state) + local tq = new("TradeQuery"):TradeQuery({ activeItemSet = {}, slots = {}, sockets = {} }) + tq.slotTables[1] = { slotName = "Ring 1" } + tq.resultTbl = state.resultTbl or {} + tq.sortedResultTbl = state.sortedResultTbl or {} + return tq + end + + local function buildRow1Dropdown(tq) + tq:PriceItemRowDisplay(1, nil, 0, 20) + return tq.controls.resultDropdown1 + end + before_each(function() mock_tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) end) describe("result dropdown tooltipFunc", function() - -- Builds a TradeQuery with the strict minimum needed for - -- PriceItemRowDisplay to construct row 1 without exploding. Only the - -- 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. - local function newTradeQuery(state) - 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 - return tq - end - - -- Builds row 1 of the trader UI and returns the dropdown that owns the - -- tooltipFunc we want to exercise. - local function buildRow1Dropdown(tq) - tq:PriceItemRowDisplay(1, nil, 0, 20) - 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 } @@ -70,25 +59,10 @@ 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"):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("replacement slot resolution", function() it("resolves normal, Abyssal, and selected jewel slots without stored row state", function() - local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + local tq = newTradeQuery({}) tq.itemsTab.slots = { ["Ring 1"] = {}, ["Body Armour Abyssal Socket 1"] = {}, @@ -111,8 +85,7 @@ describe("TradeQuery", function() describe("attribute requirement result filtering", function() local function newTradeQueryWithOutput(output, slotTbl) local calcCalls = 0 - local lastCalcArgs - local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + local tq = newTradeQuery({}) tq.slotTables[1] = slotTbl or { slotName = "Ring 1" } tq.resultTbl = { [1] = { @@ -120,40 +93,82 @@ describe("TradeQuery", function() }, } tq.sortModes = { - Weight = "(Highest) Weighted Sum", + Weight = "Weight", StatValue = "StatValue", StatValuePrice = "StatValuePrice", Price = "Price", } + tq.itemSortSelectionList = { tq.sortModes.Weight } + tq.statSortSelectionList = { { stat = "Life", weightMult = 1 } } + tq.tradeQueryGenerator = mock_queryGen + tq.pbLeague = "Test League" + tq.pbCurrencyConversion = { [tq.pbRealm] = { [tq.pbLeague] = { chaos = 1 } } } tq.itemsTab.build = { calcsTab = { GetMiscCalculator = function() return function(calcArgs) calcCalls = calcCalls + 1 - lastCalcArgs = calcArgs - return output - end, {} + return type(output) == "function" and output(calcArgs) or output + end, { Life = 100 } end, }, } tq.itemsTab.slots = { ["Ring 1"] = {}, } - return tq, function() return calcCalls end, function() return lastCalcArgs end + return tq, function() return calcCalls end end - it("filters fetched results that do not meet attribute requirements", function() - 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) + for _, mode in ipairs({ "Weight", "StatValue", "StatValuePrice", "Price" }) do + it("postfilters mixed fetched results in " .. mode .. " mode", function() + local tq = newTradeQueryWithOutput(function(calcArgs) + assert.are.equal("Ring 1", calcArgs.repSlotName) + if calcArgs.repItem.name == "Behemoth Hold, Gold Ring" then + return { ReqStr = 50, Str = 40, Life = 400 } + end + assert.are.equal("Survivor Hold, Gold Ring", calcArgs.repItem.name) + return { ReqStr = 50, Str = 60, ReqDex = 30, Dex = 30, ReqInt = 20, Int = 25, Life = 120 } + end) + -- The rejected result arrives first, costs less, and has more Life. + local survivor = { item_string = "Rarity: RARE\nSurvivor Hold\nGold Ring", amount = 7, currency = "chaos" } + tq.resultTbl[1][2] = survivor + tq.hideResultsFailingAttributeRequirements = true + local sortedItems, err = tq:SortFetchResults(1, tq.sortModes[mode]) + assert.is_nil(err) + assert.are.equal(1, #sortedItems) + assert.are.equal(2, sortedItems[1].index) + assert.are.equal(survivor, tq.resultTbl[1][sortedItems[1].index]) + end) + end + + it("clears the visible selection and price when postfiltering removes every result", function() + local tq = newTradeQueryWithOutput({ ReqStr = 50, Str = 40 }) + local dropdown = buildRow1Dropdown(tq) + tq.controls.fullPrice = new("LabelControl"):LabelControl(nil, { 0, 0, 100, 20 }, "") + tq.controls.pbNotice = new("LabelControl"):LabelControl(nil, { 0, 0, 100, 20 }, "") + -- Populate the row through the same path first, so stale state can be detected. + tq:UpdateControlsWithItems(1) + assert.is_not_nil(dropdown:GetSelValue()) + assert.are.equal(1, tq.itemIndexTbl[1]) + assert.are.equal("1 chaos", tq:GetTotalPriceString()) + assert.is_true(tq.controls.importButton1:IsEnabled()) + local populatedPriceLabel = tq.controls.fullPrice.label - 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) + tq:UpdateControlsWithItems(1) + assert.are.equal(0, dropdown:GetDropCount()) + assert.is_nil(dropdown:GetSelValue()) + assert.are.equal(0, #tq.sortedResultTbl[1]) + assert.is_nil(tq.itemIndexTbl[1]) + assert.is_nil(tq.totalPrice[1]) + assert.are.equal("", tq:GetTotalPriceString()) + assert.are_not.equal(populatedPriceLabel, tq.controls.fullPrice.label) + assert.not_matches("chaos", tq.controls.fullPrice.label, 1, true) + assert.matches("attribute requirements", tq.controls.pbNotice.label, 1, true) + assert.is_falsy(tq.controls.importButton1:IsEnabled()) + assert.are.equal("", tq.controls.whisperButton1:GetProperty("label")) + local tooltip = new("Tooltip"):Tooltip() + for _, control in ipairs({ dropdown, tq.controls.importButton1, tq.controls.whisperButton1 }) do + control.tooltipFunc(tooltip, "DROP", 1, nil) + assert.are.equal(0, #tooltip.lines) + end end) it("filters fetched results that do not meet Omniscience requirements", function() From 6c55519e892167e52ef5e00ce12194d65c046199 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 9 Oct 2026 15:04:46 +0200 Subject: [PATCH 6/8] Keep trade-query tooltip fixtures in their original scope Construct the filtering row directly instead of sharing helpers across test groups. Retain the sort and empty-row assertions while keeping the cumulative test diff smaller. --- spec/System/TestTradeQuery_spec.lua | 44 ++++++++++++++++++----------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 3f65fda9ced..d27e02b2cb6 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -2,24 +2,35 @@ describe("TradeQuery", function() local mock_tradeQuery local mock_queryGen - local function newTradeQuery(state) - local tq = new("TradeQuery"):TradeQuery({ activeItemSet = {}, slots = {}, sockets = {} }) - tq.slotTables[1] = { slotName = "Ring 1" } - tq.resultTbl = state.resultTbl or {} - tq.sortedResultTbl = state.sortedResultTbl or {} - return tq - end - - local function buildRow1Dropdown(tq) - tq:PriceItemRowDisplay(1, nil, 0, 20) - return tq.controls.resultDropdown1 - end - before_each(function() mock_tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) end) describe("result dropdown tooltipFunc", function() + -- Builds a TradeQuery with the strict minimum needed for + -- PriceItemRowDisplay to construct row 1 without exploding. Only the + -- 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. + local function newTradeQuery(state) + 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 + return tq + end + + -- Builds row 1 of the trader UI and returns the dropdown that owns the + -- tooltipFunc we want to exercise. + local function buildRow1Dropdown(tq) + tq:PriceItemRowDisplay(1, nil, 0, 20) + 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 } @@ -62,7 +73,7 @@ describe("TradeQuery", function() end) describe("replacement slot resolution", function() it("resolves normal, Abyssal, and selected jewel slots without stored row state", function() - local tq = newTradeQuery({}) + local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) tq.itemsTab.slots = { ["Ring 1"] = {}, ["Body Armour Abyssal Socket 1"] = {}, @@ -85,7 +96,7 @@ describe("TradeQuery", function() describe("attribute requirement result filtering", function() local function newTradeQueryWithOutput(output, slotTbl) local calcCalls = 0 - local tq = newTradeQuery({}) + local tq = new("TradeQuery"):TradeQuery({ activeItemSet = {}, slots = {}, sockets = {} }) tq.slotTables[1] = slotTbl or { slotName = "Ring 1" } tq.resultTbl = { [1] = { @@ -140,7 +151,8 @@ describe("TradeQuery", function() it("clears the visible selection and price when postfiltering removes every result", function() local tq = newTradeQueryWithOutput({ ReqStr = 50, Str = 40 }) - local dropdown = buildRow1Dropdown(tq) + tq:PriceItemRowDisplay(1, nil, 0, 20) + local dropdown = tq.controls.resultDropdown1 tq.controls.fullPrice = new("LabelControl"):LabelControl(nil, { 0, 0, 100, 20 }, "") tq.controls.pbNotice = new("LabelControl"):LabelControl(nil, { 0, 0, 100, 20 }, "") -- Populate the row through the same path first, so stale state can be detected. From f980ec7438855bebb8b064a16191516c65e9c6b1 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 10 Oct 2026 11:46:49 +0200 Subject: [PATCH 7/8] Reduce trade-query test setup Keep the sort, empty-row and generated-query checks while removing unused fixture fields, a single-use helper and redundant assertions. --- spec/System/TestTradeQueryGenerator_spec.lua | 87 +++++++------------- spec/System/TestTradeQuery_spec.lua | 61 +++++--------- 2 files changed, 52 insertions(+), 96 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 8ef0094a9f0..3ab8c0ec065 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -5,7 +5,7 @@ describe("TradeQueryGenerator", function() local function findStatFilter(queryTable, id) for _, group in ipairs(queryTable.query.stats) do - for _, filter in ipairs(group.filters or {}) do + for _, filter in ipairs(group.filters) do if filter.id == id then return filter end @@ -15,13 +15,11 @@ describe("TradeQueryGenerator", function() local function finishQueryWithAttributeShortfall(shortfall, includeAttributeRequirementFilters) local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) - local queryTable - local errMsg + local queryTable, 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 @@ -29,66 +27,25 @@ describe("TradeQueryGenerator", function() queryGen.calcContext = { itemCategoryQueryStr = "accessory.ring", special = {}, - testItem = { - BuildAndParseRaw = function() end, - }, + testItem = { BuildAndParseRaw = function() end }, baseOutput = { TotalDPS = 100 }, - baseStatValue = 0, options = { statWeights = { { stat = "TotalDPS", weightMult = 1 } }, - includeAllWEMods = false, includeAttributeRequirementFilters = includeAttributeRequirementFilters, includeMirrored = true, - influence1 = 1, - influence2 = 1, }, attributeRequirementShortfall = shortfall, } local previousClosePopup = main.ClosePopup main.ClosePopup = function() end - local ok, finishError = pcall(function() - queryGen:FinishQuery() - end) + local ok, finishError = pcall(queryGen.FinishQuery, queryGen) 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 @@ -295,15 +252,35 @@ describe("TradeQueryGenerator", function() 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, + local calcArgs + local calcsTab = { + GetMiscCalculator = function() + return function(args) + calcArgs = args + return { + TotalDPS = 100, + ReqStr = 50, Str = 40, + ReqDex = 30, Dex = 35, + ReqInt = 25, Int = 20, + } + end, { TotalDPS = 100 } + end, + } + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ + itemsTab = { + items = { + [1] = { baseName = "Gold Ring" }, + }, + build = { calcsTab = calcsTab }, + }, + }) + local previousOpenPopup = main.OpenPopup + main.OpenPopup = function() return {} end + local ok, startError = pcall(queryGen.StartQuery, queryGen, { slotName = "Ring 1", selItemId = 1 }, { + statWeights = { { stat = "TotalDPS", weightMult = 1 } }, }) + main.OpenPopup = previousOpenPopup + assert.is_true(ok, startError) 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) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index d27e02b2cb6..b8a18ac06eb 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -35,9 +35,7 @@ describe("TradeQuery", function() local tq = newTradeQuery({}) tq.slotTables[1] = { slotName = "Watcher's Eye", unique = true } - assert.has_no.errors(function() - buildRow1Dropdown(tq) - end) + assert.is_not_nil(buildRow1Dropdown(tq)) end) it("returns early when sortedResultTbl[row_idx] is missing", function() @@ -72,36 +70,29 @@ describe("TradeQuery", function() 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] = {} } + it("resolves Abyssal and selected jewel slots without stored row state", function() + local tq = new("TradeQuery"):TradeQuery({ + slots = { ["Body Armour Abyssal Socket 1"] = {} }, + 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)) + assert.are.equal("Body Armour Abyssal Socket 1", tq:GetReplacementSlotName(1)) + assert.are.equal("Jewel 123", tq:GetReplacementSlotName(2)) + assert.is_nil(tq:GetReplacementSlotName(3)) end) end) describe("attribute requirement result filtering", function() local function newTradeQueryWithOutput(output, slotTbl) - local calcCalls = 0 - local tq = new("TradeQuery"):TradeQuery({ activeItemSet = {}, slots = {}, sockets = {} }) + local tq = new("TradeQuery"):TradeQuery({ activeItemSet = {}, slots = { ["Ring 1"] = {} }, sockets = {} }) 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.resultTbl[1] = { + { item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring", amount = 1, currency = "chaos" }, } tq.sortModes = { Weight = "Weight", StatValue = "StatValue", StatValuePrice = "StatValuePrice", Price = "Price", @@ -114,21 +105,15 @@ describe("TradeQuery", function() tq.itemsTab.build = { calcsTab = { GetMiscCalculator = function() - return function(calcArgs) - calcCalls = calcCalls + 1 - return type(output) == "function" and output(calcArgs) or output - end, { Life = 100 } + return type(output) == "function" and output or function() return output end, { Life = 100 } end, }, } - tq.itemsTab.slots = { - ["Ring 1"] = {}, - } - return tq, function() return calcCalls end + return tq end for _, mode in ipairs({ "Weight", "StatValue", "StatValuePrice", "Price" }) do - it("postfilters mixed fetched results in " .. mode .. " mode", function() + it("filters mixed fetched results in " .. mode .. " mode", function() local tq = newTradeQueryWithOutput(function(calcArgs) assert.are.equal("Ring 1", calcArgs.repSlotName) if calcArgs.repItem.name == "Behemoth Hold, Gold Ring" then @@ -138,18 +123,16 @@ describe("TradeQuery", function() return { ReqStr = 50, Str = 60, ReqDex = 30, Dex = 30, ReqInt = 20, Int = 25, Life = 120 } end) -- The rejected result arrives first, costs less, and has more Life. - local survivor = { item_string = "Rarity: RARE\nSurvivor Hold\nGold Ring", amount = 7, currency = "chaos" } - tq.resultTbl[1][2] = survivor + tq.resultTbl[1][2] = { item_string = "Rarity: RARE\nSurvivor Hold\nGold Ring", amount = 7, currency = "chaos" } tq.hideResultsFailingAttributeRequirements = true local sortedItems, err = tq:SortFetchResults(1, tq.sortModes[mode]) assert.is_nil(err) assert.are.equal(1, #sortedItems) assert.are.equal(2, sortedItems[1].index) - assert.are.equal(survivor, tq.resultTbl[1][sortedItems[1].index]) end) end - it("clears the visible selection and price when postfiltering removes every result", function() + it("clears the visible selection and price when filtering removes every result", function() local tq = newTradeQueryWithOutput({ ReqStr = 50, Str = 40 }) tq:PriceItemRowDisplay(1, nil, 0, 20) local dropdown = tq.controls.resultDropdown1 @@ -161,17 +144,15 @@ describe("TradeQuery", function() assert.are.equal(1, tq.itemIndexTbl[1]) assert.are.equal("1 chaos", tq:GetTotalPriceString()) assert.is_true(tq.controls.importButton1:IsEnabled()) - local populatedPriceLabel = tq.controls.fullPrice.label + assert.matches("chaos", tq.controls.fullPrice.label, 1, true) tq.hideResultsFailingAttributeRequirements = true tq:UpdateControlsWithItems(1) assert.are.equal(0, dropdown:GetDropCount()) - assert.is_nil(dropdown:GetSelValue()) assert.are.equal(0, #tq.sortedResultTbl[1]) assert.is_nil(tq.itemIndexTbl[1]) assert.is_nil(tq.totalPrice[1]) assert.are.equal("", tq:GetTotalPriceString()) - assert.are_not.equal(populatedPriceLabel, tq.controls.fullPrice.label) assert.not_matches("chaos", tq.controls.fullPrice.label, 1, true) assert.matches("attribute requirements", tq.controls.pbNotice.label, 1, true) assert.is_falsy(tq.controls.importButton1:IsEnabled()) @@ -191,20 +172,18 @@ describe("TradeQuery", function() 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 tq = newTradeQueryWithOutput(function() error("Unexpected attribute recalculation") end) 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 }) + local tq = newTradeQueryWithOutput(function() error("Unexpected attribute recalculation") end, { 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("GetResultEvaluation", function() From 38a885580d0dbc4810a3740e3904fbc0616f776b Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 10 Oct 2026 18:20:32 +0200 Subject: [PATCH 8/8] Account for attribute scaling in trade query filters Convert final attribute deficits to raw item minimums using the native modifier database and rounding. Cap the result at the existing deficit because a replacement can increase attribute scaling itself. Cover amplified attributes and candidate scaling with two native cases sharing the same query fixture. --- spec/System/TestTradeQueryGenerator_spec.lua | 54 ++++++++++++++++---- src/Classes/TradeQueryGenerator.lua | 30 ++++++++--- src/Modules/Calcs.lua | 4 +- 3 files changed, 68 insertions(+), 20 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 3ab8c0ec065..efb6e407018 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -3,17 +3,17 @@ local dkjson = require "dkjson" describe("TradeQueryGenerator", function() local mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) - local function findStatFilter(queryTable, id) + local function findStatFilter(queryTable, id, groupType) for _, group in ipairs(queryTable.query.stats) do for _, filter in ipairs(group.filters) do - if filter.id == id then + if filter.id == id and (not groupType or group.type == groupType) then return filter end end end end - local function finishQueryWithAttributeShortfall(shortfall, includeAttributeRequirementFilters) + local function finishQueryWithAttributeMinimums(minimums, includeAttributeRequirementFilters) local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) local queryTable, errMsg queryGen.modWeights = { @@ -34,7 +34,7 @@ describe("TradeQueryGenerator", function() includeAttributeRequirementFilters = includeAttributeRequirementFilters, includeMirrored = true, }, - attributeRequirementShortfall = shortfall, + attributeRequirementMinimums = minimums, } local previousClosePopup = main.ClosePopup @@ -251,8 +251,42 @@ describe("TradeQueryGenerator", function() end) describe("attribute requirement filters", function() - it("calculates the shortfall from the blank replacement output", function() + for _, case in ipairs({ + { name = "admits an equipped candidate whose Strength is amplified", customMods = "+50 to Strength\n100% increased Strength", originalStrength = 30, candidateMods = "+26 to Strength", strength = 192, minimum = 26 }, + { name = "does not tighten the filter when a candidate increases reduced Strength", customMods = "+150 to Strength\n10% reduced Strength", originalStrength = 50, candidateMods = "+38 to Strength\n6% increased Strength", strength = 200, minimum = 38 }, + }) do + it(case.name, function() + newBuild() + build.characterLevel, build.characterLevelAutoMode = 100, false + build.configTab.input.customMods = case.customMods + build.configTab:BuildModList() + build.itemsTab:AddItem(new("Item"):Item("Rarity: RARE\nRequirement Anchor\nGlorious Plate\nImplicits: 0")) + build.itemsTab:AddItem(new("Item"):Item("Rarity: RARE\nOriginal Ring\nGold Ring\nImplicits: 0\n+" .. case.originalStrength .. " to Strength")) + build.modFlag, build.buildFlag = true, true + runCallback("OnFrame") + local candidate = new("Item"):Item("Rarity: RARE\nCandidate Ring\nGold Ring\nImplicits: 0\n" .. case.candidateMods) + local calc = build.calcsTab:GetMiscCalculator() + local equipped = calc({ repSlotName = "Ring 1", repItem = candidate }) + assert.are.equal(case.strength, equipped.Str) + assert.are.equal(191, equipped.ReqStr) + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = build.itemsTab }) + local query + queryGen.requesterCallback = function(_, queryJSON) query = dkjson.decode(queryJSON) end + queryGen:StartQuery(build.itemsTab.slots["Ring 1"], { + statWeights = { { stat = "Life", weightMult = 1 } }, + includeAttributeRequirementFilters = true, includeMirrored = true, + }) + while queryGen.calcContext.co do queryGen:OnFrame() end + assert.are.equal(case.minimum, findStatFilter(query, "pseudo.pseudo_total_strength", "and").value.min) + end) + end + + it("calculates attribute minimums from the blank replacement output", function() local calcArgs + local modDB = new("ModDB"):ModDB() + for attribute, value in pairs({ Str = 40, Dex = 35, Int = 20 }) do + modDB:NewMod(attribute, "BASE", value, "Fixture") + end local calcsTab = { GetMiscCalculator = function() return function(args) @@ -262,7 +296,7 @@ describe("TradeQueryGenerator", function() ReqStr = 50, Str = 40, ReqDex = 30, Dex = 35, ReqInt = 25, Int = 20, - } + }, modDB end, { TotalDPS = 100 } end, } @@ -283,11 +317,11 @@ describe("TradeQueryGenerator", function() assert.is_true(ok, startError) 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) + assert.same({ Str = 10, Dex = 0, Int = 5 }, queryGen.calcContext.attributeRequirementMinimums) end) it("adds needed attribute pseudo filters to the generated query", function() - local queryTable, errMsg = finishQueryWithAttributeShortfall({ Str = 12, Dex = 34, Int = 56 }, true) + local queryTable, errMsg = finishQueryWithAttributeMinimums({ 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) @@ -295,8 +329,8 @@ describe("TradeQueryGenerator", function() 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) + local disabledQuery = finishQueryWithAttributeMinimums({ Str = 12, Dex = 34, Int = 56 }, false) + local zeroQuery = finishQueryWithAttributeMinimums({ 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")) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 8fcfacbcda4..6c22c987fe3 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -7,6 +7,7 @@ local dkjson = require "dkjson" local curl = require("lcurl.safe") local m_max = math.max +local m_min = math.min local s_format = string.format local t_insert = table.insert local tradeHelpers = require("Classes.TradeHelpers") @@ -765,11 +766,24 @@ 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 - -- Pre-fetch constraint: require the replacement to restore attributes lost with the current item. - local attributeRequirementShortfall = { Str = 0, Dex = 0, Int = 0 } + local baseItemOutput, baseItemModDB = baseOutput + if slot then + baseItemOutput, baseItemModDB = calcFunc({ repSlotName = slot.slotName, repItem = testItem }) + end + -- Pre-fetch constraint: conservatively narrow the replacement's raw attributes. + local attributeRequirementMinimums = { Str = 0, Dex = 0, Int = 0 } if slot and (not slot.slotName:find("Flask")) then - attributeRequirementShortfall = tradeHelpers.getAttributeRequirementShortfall(baseItemOutput) + attributeRequirementMinimums = tradeHelpers.getAttributeRequirementShortfall(baseItemOutput) + for _, attribute in ipairs({ "Str", "Dex", "Int" }) do + if (attributeRequirementMinimums[attribute] or 0) > 0 then + local multiplier = calcLib.mod(baseItemModDB, nil, attribute) + -- Trade counts raw item attributes; PoB rounds the amplified build total. + -- Cap at the final shortfall: the candidate can increase attribute scaling. + attributeRequirementMinimums[attribute] = multiplier > 0 and m_min(attributeRequirementMinimums[attribute], m_max(0, + math.ceil((baseItemOutput["Req" .. attribute] - 0.5) / multiplier + - baseItemModDB:Sum("BASE", nil, attribute)))) or 0 + end + end end -- make weights more human readable local compStatValue = TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, baseItemOutput, options.statWeights) * 1000 @@ -790,7 +804,7 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) slot = slot, requiredMods = options.requiredMods, blockedMods = options.blockedMods, - attributeRequirementShortfall = attributeRequirementShortfall, + attributeRequirementMinimums = attributeRequirementMinimums, } -- OnFrame will pick this up and begin the work @@ -1068,9 +1082,9 @@ function TradeQueryGeneratorClass:FinishQuery() end local options = self.calcContext.options - -- If enabled, require the new item to provide enough attributes to meet build requirements. - if options.includeAttributeRequirementFilters and self.calcContext.attributeRequirementShortfall then - local need = self.calcContext.attributeRequirementShortfall + -- Add the conservative raw-attribute minimums when enabled. + if options.includeAttributeRequirementFilters and self.calcContext.attributeRequirementMinimums then + local need = self.calcContext.attributeRequirementMinimums if need.Str and need.Str > 0 then complexityBudget = complexityBudget - 4 t_insert(andGroup.filters, { id = "pseudo.pseudo_total_strength", value = { min = need.Str } }) diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index dc12041374f..2a1d30dd9c6 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -84,7 +84,7 @@ end -- Get calculator for other changes (adding/removing nodes, items, gems, etc) ---@param build Build ----@return fun(override?: CalcOverride, useFullDPS?: boolean): Output calcFunc +---@return fun(override?: CalcOverride, useFullDPS?: boolean): Output, ModDB calcFunc ---@return Output output function calcs.getMiscCalculator(build) -- Run base calculation pass @@ -109,7 +109,7 @@ function calcs.getMiscCalculator(build) env.player.output.FullDPS = fullDPS.combinedDPS env.player.output.FullDotDPS = fullDPS.TotalDotDPS end - return env.player.output + return env.player.output, env.modDB end, env.player.output end