diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index e11ea701b9..8fd79ced69 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -1,6 +1,94 @@ +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, includeAttributeRequirementFilters) + local queryGen = new("TradeQueryGenerator"):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 = "accessory.ring", + special = {}, + 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) + 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 @@ -192,4 +280,40 @@ describe("TradeQueryGenerator", function() assert.is_not_nil(query.filters.socket_filters.filters.links) end) 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) + assert.are.equal(12, findStatFilter(queryTable, "pseudo.pseudo_total_strength").value.min) + assert.are.equal(34, findStatFilter(queryTable, "pseudo.pseudo_total_dexterity").value.min) + assert.are.equal(56, findStatFilter(queryTable, "pseudo.pseudo_total_intelligence").value.min) + end) + + it("omits attribute pseudo filters when disabled or no shortfall exists", function() + local disabledQuery = finishQueryWithAttributeShortfall({ Str = 12, Dex = 34, Int = 56 }, false) + local zeroQuery = finishQueryWithAttributeShortfall({ Str = 0, Dex = 0, Int = 0 }, true) + assert.is_nil(findStatFilter(disabledQuery, "pseudo.pseudo_total_strength")) + assert.is_nil(findStatFilter(disabledQuery, "pseudo.pseudo_total_dexterity")) + assert.is_nil(findStatFilter(disabledQuery, "pseudo.pseudo_total_intelligence")) + assert.is_nil(findStatFilter(zeroQuery, "pseudo.pseudo_total_strength")) + assert.is_nil(findStatFilter(zeroQuery, "pseudo.pseudo_total_dexterity")) + assert.is_nil(findStatFilter(zeroQuery, "pseudo.pseudo_total_intelligence")) + end) + end) end) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 9a83a331c4..a128a752c4 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -9,7 +9,7 @@ describe("TradeQuery", function() describe("result dropdown tooltipFunc", function() -- Builds a TradeQuery with the strict minimum needed for -- PriceItemRowDisplay to construct row 1 without exploding. Only the - -- two itemsTab subtables read by the slot lookup at the top of + -- three itemsTab fields read by the slot lookup at the top of -- PriceItemRowDisplay need to be created here; everything else either -- lives behind a callback we never trigger, or is already initialized -- by the TradeQuery constructor. @@ -17,6 +17,7 @@ describe("TradeQuery", function() local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) tq.itemsTab.activeItemSet = {} tq.itemsTab.slots = {} + tq.itemsTab.sockets = {} tq.slotTables[1] = { slotName = "Ring 1" } if state.resultTbl then tq.resultTbl = state.resultTbl end if state.sortedResultTbl then tq.sortedResultTbl = state.sortedResultTbl end @@ -30,6 +31,15 @@ describe("TradeQuery", function() return tq.controls.resultDropdown1 end + it("constructs the Watcher's Eye row without an active jewel socket", function() + local tq = newTradeQuery({}) + tq.slotTables[1] = { slotName = "Watcher's Eye", unique = true } + + assert.has_no.errors(function() + buildRow1Dropdown(tq) + end) + end) + it("returns early when sortedResultTbl[row_idx] is missing", function() -- No sorted results at all -> first guard must short-circuit. local tq = newTradeQuery({}) @@ -60,6 +70,115 @@ 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 = {} }) + 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 = { + [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(calcArgs) + calcCalls = calcCalls + 1 + lastCalcArgs = calcArgs + return output + end, {} + end, + }, + } + tq.itemsTab.slots = { + ["Ring 1"] = {}, + } + return tq, function() return calcCalls end, function() return lastCalcArgs 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) + + it("keeps fetched results that meet attribute requirements", function() + local tq = newTradeQueryWithOutput({ ReqStr = 50, Str = 60, ReqDex = 30, Dex = 30, ReqInt = 20, Int = 25 }) + tq.hideResultsFailingAttributeRequirements = true + local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) + assert.are.equal(1, #sortedItems) + assert.are.equal(1, sortedItems[1].index) + end) + + it("filters fetched results that do not meet Omniscience requirements", function() + local tq = newTradeQueryWithOutput({ ReqOmni = 100, Omni = 80 }) + tq.hideResultsFailingAttributeRequirements = true + local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) + assert.are.equal(0, #sortedItems) + end) + + it("keeps fetched results without recalculating by default", function() + local tq, calcCalls = newTradeQueryWithOutput({ ReqStr = 50, Str = 40, ReqDex = 0, Dex = 0, ReqInt = 0, Int = 0 }) + local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) + assert.are.equal(1, #sortedItems) + assert.are.equal(1, sortedItems[1].index) + assert.are.equal(0, calcCalls()) + end) + + it("does not apply equipment attribute filtering to rows without a replacement slot", function() + local tq, calcCalls = newTradeQueryWithOutput({ ReqStr = 50, Str = 40, ReqDex = 0, Dex = 0, ReqInt = 0, Int = 0 }, { slotName = "Megalomaniac", unique = true }) + tq.hideResultsFailingAttributeRequirements = true + local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) + assert.are.equal(1, #sortedItems) + assert.are.equal(1, sortedItems[1].index) + assert.are.equal(0, calcCalls()) + end) end) describe("ReduceOutput", function() it("preserves lower-is-better values for weighted result comparison", function() diff --git a/src/Classes/TradeHelpers.lua b/src/Classes/TradeHelpers.lua index 4448bd0a03..395658efcc 100644 --- a/src/Classes/TradeHelpers.lua +++ b/src/Classes/TradeHelpers.lua @@ -1,9 +1,10 @@ -- Path of Building -- -- Module: Compare Trade Helpers --- Stateless trade mod lookup/matching and item display helper functions +-- Stateless trade matching, attribute requirement, and item display helpers -- local m_floor = math.floor +local m_max = math.max local statDescData = require("Data.StatDescriptions.stat_descriptions") -- precalculate patterns used for matching stat lines @@ -36,6 +37,30 @@ end local M = {} +local attributeOutputKeys = { "Str", "Dex", "Int" } + +-- Shared by pre-fetch query constraints and post-fetch candidate validation. +-- Omniscience replaces the individual attribute requirements when it is active. +function M.getAttributeRequirementShortfall(output) + if (output.ReqOmni or 0) > 0 then + return { Omni = m_max(0, output.ReqOmni - (output.Omni or 0)) } + end + local shortfall = {} + for _, attributeKey in ipairs(attributeOutputKeys) do + shortfall[attributeKey] = m_max(0, (output["Req" .. attributeKey] or 0) - (output[attributeKey] or 0)) + end + return shortfall +end + +function M.meetsAttributeRequirements(output) + for _, missingAmount in pairs(M.getAttributeRequirementShortfall(output)) do + if missingAmount > 0 then + return false + end + end + return true +end + -- Helper: get rarity color code for an item --- @param item table function M.getRarityColor(item) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 7ea2fcedf0..d746ac6d84 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -7,6 +7,7 @@ local dkjson = require "dkjson" local itemSlotHelper = LoadModule("Modules/ItemSlotHelper") +local tradeHelpers = LoadModule("Classes/TradeHelpers") local get_time = os.time local t_insert = table.insert @@ -38,6 +39,7 @@ function TradeQueryClass:TradeQuery(itemsTab) -- default set of trade item sort selection self.slotTables = { } self.pbItemSortSelectionIndex = 1 + self.hideResultsFailingAttributeRequirements = false -- for each realm and league, a table of values of each currency in div --- @type table>> self.pbCurrencyConversion = {} @@ -457,6 +459,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:") + -- 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.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.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.hideResultsFailingAttributeRequirementsCheck.state = self.hideResultsFailingAttributeRequirements + -- Realm selection self.controls.realmLabel = new("LabelControl"):LabelControl({"LEFT", self.controls.setSelect, "RIGHT"}, {18, 0, 20, row_height - 4}, "^7Realm:") self.controls.realm = new("DropDownControl"):DropDownControl({"LEFT", self.controls.realmLabel, "RIGHT"}, {6, 0, 150, row_height}, self.realmDropList, function(index, value) @@ -558,7 +574,9 @@ Highest Weight - Displays the order retrieved from trade]] t_insert(slotTables, { slotName = self.itemsTab.sockets[nodeId].label, nodeId = nodeId }) end - self.controls.sectionAnchor = new("LabelControl"):LabelControl({"LEFT", self.controls.tradeTypeSelection, "LEFT"}, {0, row_vertical_padding, 0, 0}, "") + -- Base Y offset for sectionAnchor (used to preserve position when scrollbar shifts it) + local sectionAnchorBaseY = row_vertical_padding + row_height + self.controls.sectionAnchor = new("LabelControl"):LabelControl({"LEFT", self.controls.tradeTypeSelection, "LEFT"}, {0, sectionAnchorBaseY, 0, 0}, "") top_pane_alignment_ref = {"TOPLEFT", self.controls.sectionAnchor, "TOPLEFT"} local scrollBarShown = #slotTables > 21 -- clipping starts beyond this -- dynamically hide rows that are above or below the scrollBar @@ -633,7 +651,7 @@ Highest Weight - Displays the order retrieved from trade]] local function scrollBarFunc() self.controls.scrollBar.height = self.pane_height-100 self.controls.scrollBar:SetContentDimension(self.pane_height-100, self.effective_rows_height) - self.controls.sectionAnchor.y = -self.controls.scrollBar.offset + self.controls.sectionAnchor.y = sectionAnchorBaseY - self.controls.scrollBar.offset end local function onRateLimit(backoff) @@ -749,7 +767,7 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) for row_idx in pairs(self.resultTbl) do self:UpdateControlsWithItems(row_idx) end - end) + end) controls.cancel = new("ButtonControl"):ButtonControl({ "BOTTOM", nil, "BOTTOM" }, { 0, -10, 80, 20 }, "Cancel", function() if previousSelectionList and #previousSelectionList > 0 then self.statSortSelectionList = copyTable(previousSelectionList, true) @@ -795,6 +813,31 @@ function TradeQueryClass:ReduceOutput(output) return smallOutput end +-- 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 self.itemsTab.sockets and self.itemsTab.sockets[jewelNodeId] + end + if slotTbl.fullName then + return self.itemsTab.slots and self.itemsTab.slots[slotTbl.fullName] + end + return self.itemsTab.slots and self.itemsTab.slots[slotTbl.slotName] +end + +function TradeQueryClass:GetReplacementSlotName(row_idx) + local slotTbl = self.slotTables[row_idx] + if not slotTbl or not self:GetReplacementSlot(row_idx) then + return nil + end + local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId + return jewelNodeId and "Jewel " .. tostring(jewelNodeId) or slotTbl.fullName or slotTbl.slotName +end + -- Method to evaluate a result by getting it's output and weight function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, baseOutput) local result = self.resultTbl[row_idx][result_index] @@ -814,9 +857,7 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba self.onlyWeightedBaseOutput[row_idx][result_index] = onlyWeightedBaseOutput self.lastComparedWeightList[row_idx][result_index] = self.statSortSelectionList end - local slotTbl = self.slotTables[row_idx] - local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId - local slotName = jewelNodeId and "Jewel " .. tostring(jewelNodeId) or slotTbl.slotName + local slotName = self:GetReplacementSlotName(row_idx) or self.slotTables[row_idx].slotName if slotName == "Megalomaniac" then local addedNodes = {} for nodeName in (result.item_string.."\r\n"):gmatch("1 Added Passive Skill is (.-)\r?\n") do @@ -854,16 +895,19 @@ function TradeQueryClass:UpdateDropdownList(row_idx) if not self.resultTbl[row_idx] then return end - for result_index = 1, #self.resultTbl[row_idx] do - - local pb_index = self.sortedResultTbl[row_idx][result_index].index - local result = self.resultTbl[row_idx][pb_index] - local price = string.format(" %s(%d %s)", colorCodes["CURRENCY"], result.amount, result.currency) - local item = new("Item"):Item(result.item_string) - table.insert(dropdownLabels, colorCodes[item.rarity] .. item.name .. price) + -- Iterate the sorted (and potentially filtered) list so attribute-filtered rows are omitted from the dropdown + for _, sorted in ipairs(self.sortedResultTbl[row_idx] or {}) do + if sorted and sorted.index and self.resultTbl[row_idx][sorted.index] then + local result = self.resultTbl[row_idx][sorted.index] + local price = string.format(" %s(%d %s)", colorCodes["CURRENCY"], result.amount, result.currency) + local item = new("Item"):Item(result.item_string) + table.insert(dropdownLabels, colorCodes[item.rarity] .. item.name .. price) + end + end + if self.controls["resultDropdown".. row_idx] then + self.controls["resultDropdown".. row_idx].selIndex = 1 + self.controls["resultDropdown".. row_idx]:SetList(dropdownLabels) end - self.controls["resultDropdown".. row_idx].selIndex = 1 - self.controls["resultDropdown".. row_idx]:SetList(dropdownLabels) end function TradeQueryClass:ResetResultRow(rowIdx) self.itemIndexTbl[rowIdx] = nil @@ -874,6 +918,23 @@ function TradeQueryClass:ResetResultRow(rowIdx) self.controls.fullPrice.label = "^7Total Price: " .. self:GetTotalPriceString() end function TradeQueryClass:UpdateControlsWithItems(row_idx) + 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.controls.fullPrice.label = "^7Total Price: " .. self:GetTotalPriceString() + end + + local results = self.resultTbl[row_idx] + if not results or #results == 0 then + clearVisibleResults() + return + end + local sortMode = self.itemSortSelectionList[self.pbItemSortSelectionIndex] local sortedItems, errMsg = self:SortFetchResults(row_idx, sortMode) if errMsg == "MissingConversionRates" then @@ -885,13 +946,14 @@ function TradeQueryClass:UpdateControlsWithItems(row_idx) else self:SetNotice(self.controls.pbNotice, "") 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.") + if not sortedItems or #sortedItems == 0 then + 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 local pb_index = sortedItems[1].index self.itemIndexTbl[row_idx] = pb_index self.controls["priceButton".. row_idx].tooltipText = "Sorted by " .. self.itemSortSelectionList[self.pbItemSortSelectionIndex] @@ -917,6 +979,26 @@ end -- Method to sort the fetched results function TradeQueryClass:SortFetchResults(row_idx, mode) local calcFunc, baseOutput + local slotName = self:GetReplacementSlotName(row_idx) + local results = self.resultTbl[row_idx] + if not results or #results == 0 then + return {} + end + + -- 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 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 }) + return tradeHelpers.meetsAttributeRequirements(output) + end + local function getResultWeight(result_index) if not calcFunc then calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() @@ -945,12 +1027,16 @@ function TradeQueryClass:SortFetchResults(row_idx, mode) local newTbl = {} if mode == self.sortModes.Weight then for index, _ in pairs(self.resultTbl[row_idx]) do - t_insert(newTbl, { outputAttr = index, index = index }) + if meetsAttributeRequirements(index) then + t_insert(newTbl, { outputAttr = index, index = index }) + end end return newTbl elseif mode == self.sortModes.StatValue then for result_index = 1, #self.resultTbl[row_idx] do - t_insert(newTbl, { outputAttr = getResultWeight(result_index), index = result_index }) + if meetsAttributeRequirements(result_index) then + t_insert(newTbl, { outputAttr = getResultWeight(result_index), index = result_index }) + end end table.sort(newTbl, function(a,b) return a.outputAttr > b.outputAttr end) elseif mode == self.sortModes.StatValuePrice then @@ -970,9 +1056,11 @@ function TradeQueryClass:SortFetchResults(row_idx, mode) -- scaling factor for price local k = 0.1 - t_insert(newTbl, - { outputAttr = getResultWeight(result_index) - k * math.log(priceTable[result_index], 10), index = - result_index }) + if meetsAttributeRequirements(result_index) then + t_insert(newTbl, + { outputAttr = getResultWeight(result_index) - k * math.log(priceTable[result_index], 10), index = + result_index }) + end end table.sort(newTbl, function(a,b) return a.outputAttr > b.outputAttr end) elseif mode == self.sortModes.Price then @@ -981,7 +1069,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 @@ -1009,11 +1099,7 @@ 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 - -- 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 @@ -1173,8 +1259,10 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end) controls["changeButton"..row_idx].shown = function() return self.resultTbl[row_idx] end controls["resultDropdown" .. row_idx] = new("DropDownControl"):DropDownControl({ "TOPLEFT", controls["changeButton" .. row_idx], "TOPRIGHT" }, { 8, 0, 351, row_height }, {}, function(index) - self.itemIndexTbl[row_idx] = self.sortedResultTbl[row_idx][index].index - self:SetFetchResultReturn(row_idx, self.itemIndexTbl[row_idx]) + if self.sortedResultTbl[row_idx] and self.sortedResultTbl[row_idx][index] then + self.itemIndexTbl[row_idx] = self.sortedResultTbl[row_idx][index].index + self:SetFetchResultReturn(row_idx, self.itemIndexTbl[row_idx]) + end end) self:UpdateDropdownList(row_idx) local function addMegalomaniacCompareToTooltipIfApplicable(tooltip, result_index) @@ -1209,8 +1297,17 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite tooltip:AddSeparator(10) tooltip:AddLine(16, string.format("^7Price: %s %s", result.amount, result.currency)) end + local function getSelectedResult() + local selected_result_index = self.itemIndexTbl[row_idx] + local rowResults = self.resultTbl[row_idx] + return selected_result_index and rowResults and rowResults[selected_result_index], selected_result_index + end controls["importButton"..row_idx] = new("ButtonControl"):ButtonControl({ "TOPLEFT", controls["resultDropdown"..row_idx], "TOPRIGHT"}, {8, 0, 100, row_height}, "Import Item", function() - self.itemsTab:CreateDisplayItemFromRaw(self.resultTbl[row_idx][self.itemIndexTbl[row_idx]].item_string) + local itemResult = getSelectedResult() + if not itemResult or not itemResult.item_string then + return + end + self.itemsTab:CreateDisplayItemFromRaw(itemResult.item_string) local item = self.itemsTab.displayItem -- pass "true" to not auto equip it as we will have our own logic self.itemsTab:AddDisplayItem(true) @@ -1226,8 +1323,8 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end) controls["importButton"..row_idx].tooltipFunc = function(tooltip) tooltip:Clear() - local selected_result_index = self.itemIndexTbl[row_idx] - local item_string = self.resultTbl[row_idx][selected_result_index].item_string + local itemResult, selected_result_index = getSelectedResult() + local item_string = itemResult and itemResult.item_string if selected_result_index and item_string then local item = new("Item"):Item(item_string) local tooltipSlot = slotTbl.selectedJewelNodeId and self.itemsTab.sockets[slotTbl.selectedJewelNodeId] or activeSlot @@ -1236,11 +1333,13 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end end controls["importButton"..row_idx].enabled = function() - return self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]].item_string ~= nil + local itemResult = getSelectedResult() + return itemResult and itemResult.item_string ~= nil end -- Whisper so we can copy to clipboard - controls["whisperButton" .. row_idx] = new("ButtonControl"):ButtonControl({ "TOPLEFT", controls["importButton" .. row_idx], "TOPRIGHT" }, { 8, 0, 155, row_height }, function() - local itemResult = self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]] + controls["whisperButton" .. row_idx] = new("ButtonControl"):ButtonControl( + { "TOPLEFT", controls["importButton" .. row_idx], "TOPRIGHT" }, { 8, 0, 155, row_height }, function() + local itemResult = getSelectedResult() if not itemResult then return "" end @@ -1256,8 +1355,11 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end end, function() - local itemResult = self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]] - if itemResult.whisper and (itemResult.priceType ~= "~b/o") then + local itemResult = getSelectedResult() + if not itemResult then + return + end + if itemResult.whisper and (itemResult.priceType ~= "~b/o") then Copy(itemResult.whisper) else local exactQuery = dkjson.decode(self.lastQueries[row_idx]) @@ -1284,7 +1386,10 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite controls["whisperButton" .. row_idx].tooltipFunc = function(tooltip) tooltip:Clear() tooltip.center = true - local itemResult = self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]] + local itemResult = getSelectedResult() + if not itemResult then + return + end local text = itemResult.whisper and "Copies the item purchase whisper to the clipboard" or "Opens the search page to show the item" tooltip:AddLine(16, text) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 9b7e3f5b40..ae80c098eb 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -740,6 +740,11 @@ 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 } + if slot and (not slot.slotName:find("Flask")) then + attributeRequirementShortfall = tradeHelpers.getAttributeRequirementShortfall(baseItemOutput) + end -- make weights more human readable local compStatValue = TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, baseItemOutput, options.statWeights) * 1000 @@ -758,6 +763,7 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) options = options, slot = slot, requiredMods = options.requiredMods, + attributeRequirementShortfall = attributeRequirementShortfall, } -- OnFrame will pick this up and begin the work @@ -1032,6 +1038,23 @@ function TradeQueryGeneratorClass:FinishQuery() filters = filters + 1 end + -- If enabled, require the new item to provide enough attributes to meet build requirements + if options.includeAttributeRequirementFilters and self.calcContext and self.calcContext.attributeRequirementShortfall then + local need = self.calcContext.attributeRequirementShortfall + if need.Str and need.Str > 0 then + t_insert(andFilters.filters, { id = "pseudo.pseudo_total_strength", value = { min = need.Str } }) + filters = filters + 1 + end + if need.Dex and need.Dex > 0 then + t_insert(andFilters.filters, { id = "pseudo.pseudo_total_dexterity", value = { min = need.Dex } }) + filters = filters + 1 + end + if need.Int and need.Int > 0 then + t_insert(andFilters.filters, { id = "pseudo.pseudo_total_intelligence", value = { min = need.Int } }) + filters = filters + 1 + end + end + if #andFilters.filters > 0 then t_insert(queryTable.query.stats, andFilters) end @@ -1293,6 +1316,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.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 controls.sockets = new("EditControl"):EditControl({"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 70, 18}, nil, nil, "%D") @@ -1385,6 +1414,10 @@ 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.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) self.lastSockets = options.sockets