From 9324ffb4aa96d47fcc9e63ec8867b5b014a7b0f6 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 14:46:33 +0200 Subject: [PATCH 1/8] Evaluate Trader results with bench craft potential Use Trade affix metadata to track empty prefix and suffix slots separately, then locally rerank fetched results with the best legal bench craft. Show the exact simulated item while Ctrl is held without changing the item imported from the listing. --- spec/System/TestTradeQueryRequests_spec.lua | 49 +++ spec/System/TestTradeQuery_spec.lua | 350 ++++++++++++++++++++ src/Classes/TradeQuery.lua | 244 +++++++++++++- src/Classes/TradeQueryGenerator.lua | 12 + src/Classes/TradeQueryRequests.lua | 64 +++- 5 files changed, 716 insertions(+), 3 deletions(-) diff --git a/spec/System/TestTradeQueryRequests_spec.lua b/spec/System/TestTradeQueryRequests_spec.lua index a872f1ecf9c..aab65c03293 100644 --- a/spec/System/TestTradeQueryRequests_spec.lua +++ b/spec/System/TestTradeQueryRequests_spec.lua @@ -238,6 +238,55 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] assert.are.equal("42", itemsById.legacy.weight) assert.are.equal("0", itemsById.empty.weight) end) + + it("preserves prefix and suffix metadata from trade modifier tiers", function() + local response = dkjson.encode({ + result = { { + id = "affix-metadata", + listing = { + price = { amount = 1, currency = "chaos", type = "~price" }, + whisper = "hi", + account = { name = "seller" }, + }, + item = { + rarity = "Rare", + name = "Test Band", + typeLine = "Sapphire Ring", + explicitMods = { + { description = "+50 to maximum Life", domain = "explicit", hash = "stat.explicit.life", mods = { { name = "Sanguine", tier = "P2", level = 50 } } }, + { description = "20% increased Armour", domain = "explicit", hash = "stat.explicit.armour", mods = { { name = "Sanguine", tier = "P2", level = 50 } } }, + { description = "+30% to Fire Resistance", domain = "explicit", hash = "stat.explicit.fire", mods = { { name = "of Craft", tier = "S3", level = 30 } } }, + { description = "+30% to Cold Resistance", domain = "explicit", hash = "stat.explicit.cold", mods = { { name = "of Craft", tier = "S3", level = 30 } } }, + }, + extended = { hashes = { explicit = { + { "explicit.life", { 0 } }, + { "explicit.armour", { 0 } }, + { "explicit.fire", { 1 } }, + { "explicit.cold", { 2 } }, + } } }, + }, + } }, + }) + local fetchedItems + requests.requestQueue.fetch = { } + requests:FetchResultBlock("test", function(items) + fetchedItems = items + end) + + local request = table.remove(requests.requestQueue.fetch, 1) + request.callback(response) + + local item = new("Item", fetchedItems[1].item_string) + assert.is_true(item.explicitModLines[1].prefix) + assert.is_true(item.explicitModLines[2].prefix) + assert.are.equal(item.explicitModLines[1].modGroup, item.explicitModLines[2].modGroup) + assert.is_true(item.explicitModLines[3].suffix) + assert.is_true(item.explicitModLines[4].suffix) + assert.are_not.equal(item.explicitModLines[3].modGroup, item.explicitModLines[4].modGroup) + local availability = new("TradeQuery", { itemsTab = { } }):GetBenchCraftAvailability(item) + assert.are.equal(2, availability.Prefix) + assert.are.equal(1, availability.Suffix) + end) end) describe("FetchResults", function() diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 9a83a331c4f..93501e6335d 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -60,6 +60,77 @@ describe("TradeQuery", function() end) assert.are.equal(0, #tooltip.lines) end) + + it("shows the simulated bench craft and its Ctrl compare hint", function() + local tq = newTradeQuery({ + resultTbl = { [1] = { [1] = { + item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring", + amount = 1, + currency = "chaos", + evaluation = { { + benchCraft = "+25 to Strength ^8(Suffix)", + benchCraftItemString = "Rarity: RARE\nBehemoth Hold\nGold Ring\nImplicits: 0\n{crafted}{suffix}+25 to Strength", + } }, + } } }, + sortedResultTbl = { [1] = { { index = 1 } } }, + }) + tq.itemsTab.AddItemTooltip = function() end + local dropdown = buildRow1Dropdown(tq) + local tooltip = new("Tooltip") + + dropdown.tooltipFunc(tooltip, "DROP", 1, nil) + + local tooltipText = "" + for _, line in ipairs(tooltip.lines) do + tooltipText = tooltipText .. (line.text or "") .. "\n" + end + assert.is_truthy(tooltipText:find("Bench craft: +25 to Strength", 1, true)) + assert.is_truthy(tooltipText:find("[Ctrl: compare]", 1, true)) + end) + + it("shows the simulated item and highlights its craft while Ctrl is held", function() + local tq = newTradeQuery({ + resultTbl = { [1] = { [1] = { + item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring\nImplicits: 0\n{prefix}+40 to maximum Mana", + amount = 1, + currency = "chaos", + evaluation = { { + benchCraft = "+25 to Strength ^8(Suffix)", + benchCraftItemString = "Rarity: RARE\nBehemoth Hold\nGold Ring\nImplicits: 0\n{prefix}+40 to maximum Mana\n{crafted}{suffix}+25 to Strength", + benchCraftLineIndexes = { 2 }, + } }, + } } }, + sortedResultTbl = { [1] = { { index = 1 } } }, + }) + tq.itemsTab.AddItemTooltip = function(_, tooltip, item) + for _, modLine in ipairs(item.explicitModLines or { }) do + tooltip:AddLine(16, colorCodes.MAGIC .. modLine.line, nil, modLine) + end + end + local previewActive = true + tq.IsBenchCraftPreviewActive = function() return previewActive end + local dropdown = buildRow1Dropdown(tq) + local tooltip = new("Tooltip") + + dropdown.tooltipFunc(tooltip, "DROP", 1, nil) + + assert.are.equal(1, #tooltip.childTooltips) + local previewText = "" + for _, line in ipairs(tooltip.childTooltips[1].lines) do + previewText = previewText .. (line.text or "") .. "\n" + end + assert.is_truthy(previewText:find("[Craft] +25 to Strength", 1, true)) + assert.is_truthy(previewText:find("Estimated with bench craft", 1, true)) + + previewActive = false + dropdown.tooltipFunc(tooltip, "DROP", 1, nil) + assert.is_nil(tooltip.childTooltips) + + previewActive = true + tq.resultTbl[1][1].evaluation = { { } } + dropdown.tooltipFunc(tooltip, "DROP", 1, nil) + assert.is_nil(tooltip.childTooltips) + end) end) describe("ReduceOutput", function() it("preserves lower-is-better values for weighted result comparison", function() @@ -114,4 +185,283 @@ describe("TradeQuery", function() assert.are.equals(1.2, result) end) end) + + describe("bench craft result evaluation", function() + local prefixCraft = { + type = "Prefix", + group = "IncreasedLife", + modTags = { "life" }, + types = { Ring = true }, + "+(51-55) to maximum Life", + } + local suffixCraft = { + type = "Suffix", + group = "Strength", + modTags = { "attribute" }, + types = { Ring = true }, + "+(21-25) to Strength", + } + + local function makeRareRing(prefixCount, suffixCount, extraLines) + local lines = { "Rarity: Rare", "Test Ring", "Sapphire Ring", "Implicits: 0" } + local prefixLines = { + "{prefix}+40 to maximum Mana", + "{prefix}20% increased Armour", + "{prefix}20% increased Evasion Rating", + } + local suffixLines = { + "{suffix}+30% to Fire Resistance", + "{suffix}+30% to Cold Resistance", + "{suffix}+30% to Lightning Resistance", + } + for index = 1, prefixCount do + table.insert(lines, prefixLines[index]) + end + for index = 1, suffixCount do + table.insert(lines, suffixLines[index]) + end + for _, line in ipairs(extraLines or { }) do + table.insert(lines, line) + end + return table.concat(lines, "\n") + end + + local function evaluate(itemString, crafts, calcOverride) + local tradeQuery = new("TradeQuery", { itemsTab = { } }) + tradeQuery.tradeQueryGenerator = mock_queryGen + tradeQuery.itemsTab.build = { data = { masterMods = crafts or { prefixCraft, suffixCraft } } } + tradeQuery.statSortSelectionList = { { stat = "Life", weightMult = 1 } } + tradeQuery.slotTables[1] = { slotName = "Ring 1", considerBenchCraft = true } + tradeQuery.resultTbl[1] = { { item_string = itemString } } + local function calc(args) + local life = 100 + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted and modLine.line:find("maximum Life", 1, true) then + life = 300 + elseif modLine.crafted and modLine.line:find("to Strength", 1, true) then + life = 150 + end + end + return { Life = life } + end + return tradeQuery:GetResultEvaluation(1, 1, calcOverride or calc, { Life = 100 })[1] + end + + it("only scores suffix crafts when the prefix side is full", function() + local evaluation = evaluate(makeRareRing(3, 2)) + + assert.are.equal(1.5, evaluation.weight) + assert.is_truthy(evaluation.benchCraft:find("to Strength", 1, true)) + assert.is_truthy(evaluation.benchCraftItemString:find("{crafted}", 1, true)) + assert.are.same({ 6 }, evaluation.benchCraftLineIndexes) + end) + + it("only scores prefix crafts when the suffix side is full", function() + local evaluation = evaluate(makeRareRing(2, 3)) + + assert.is_true(evaluation.weight > 1) + assert.is_truthy(evaluation.benchCraft:find("maximum Life", 1, true)) + end) + + it("previews the same craft roll that was used for scoring", function() + local scoredCraftLine + local evaluation = evaluate(makeRareRing(3, 2), { suffixCraft }, function(args) + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted then + scoredCraftLine = itemLib.applyRange(modLine.line, modLine.range, 1, 1) + return { Life = 200 } + end + end + return { Life = 100 } + end) + local previewItem = new("Item", evaluation.benchCraftItemString) + local previewModLine = previewItem.explicitModLines[evaluation.benchCraftLineIndexes[1]] + local previewCraftLine = itemLib.applyRange(previewModLine.line, previewModLine.range, 1, 1) + + assert.are.equal(scoredCraftLine, previewCraftLine) + assert.are.equal(main.defaultItemAffixQuality or 0.5, previewModLine.range) + end) + + it("does not add a second bench craft", function() + local evaluation = evaluate(makeRareRing(1, 1, { "{crafted}{suffix}+20 to Strength" })) + + assert.is_nil(evaluation.benchCraft) + end) + + it("allows another bench craft when the item has the multimod modifier", function() + local evaluation = evaluate(makeRareRing(1, 1, { + "{crafted}{suffix}Can have up to 3 Crafted Modifiers", + }), { prefixCraft }) + + assert.is_truthy(evaluation.benchCraft:find("maximum Life", 1, true)) + end) + + it("does not add a fourth craft when multimod affixes have distinct source indices", function() + local evaluation = evaluate(makeRareRing(1, 0, { + "{crafted}{suffix}{modGroup:trade:crafted:0}Can have up to 3 Crafted Modifiers", + "{crafted}{suffix}{modGroup:trade:crafted:1}+20% to Fire Resistance", + "{crafted}{suffix}{modGroup:trade:crafted:2}+20% to Cold Resistance", + }), { prefixCraft }) + + assert.are.equal(1, evaluation.weight) + assert.is_nil(evaluation.benchCraft) + end) + + it("does not score crafts on corrupted or mirrored items", function() + for _, marker in ipairs({ "Corrupted", "Mirrored" }) do + local evaluation = evaluate(makeRareRing(1, 1, { marker })) + assert.are.equal(1, evaluation.weight) + assert.is_nil(evaluation.benchCraft) + end + end) + + it("does not duplicate an existing affix group", function() + local evaluation = evaluate(makeRareRing(1, 3, { "{prefix}+50 to maximum Life" }), { prefixCraft }) + + assert.are.equal(1, evaluation.weight) + assert.is_nil(evaluation.benchCraft) + end) + + it("does not score an item when an explicit affix side is unknown", function() + local evaluation = evaluate(makeRareRing(0, 0, { "+50 to maximum Life" }), { suffixCraft }) + + assert.are.equal(1, evaluation.weight) + assert.is_nil(evaluation.benchCraft) + end) + + it("does not score an item without explicit affix metadata", function() + local evaluation = evaluate(makeRareRing(0, 0), { suffixCraft }) + + assert.are.equal(1, evaluation.weight) + assert.is_nil(evaluation.benchCraft) + end) + + it("does not score contradictory sides for the same trade affix", function() + local evaluation = evaluate(makeRareRing(0, 0, { + "{prefix}{modGroup:trade:explicit:0}+50 to maximum Life", + "{suffix}{modGroup:trade:explicit:0}+30% to Fire Resistance", + }), { suffixCraft }) + + assert.are.equal(1, evaluation.weight) + assert.is_nil(evaluation.benchCraft) + end) + + it("counts multi-line trade affixes once", function() + local independentPrefixCraft = copyTable(suffixCraft, true) + independentPrefixCraft.type = "Prefix" + local evaluation = evaluate(makeRareRing(1, 3, { + "{prefix}{modGroup:trade:explicit:0}+50 to maximum Life", + "{prefix}{modGroup:trade:explicit:0}20% increased Armour", + }), { independentPrefixCraft }) + + assert.is_true(evaluation.weight > 1) + assert.is_truthy(evaluation.benchCraft:find("to Strength", 1, true)) + end) + + it("reuses the parsed item without leaking prior craft candidates", function() + local crafts = { } + for index = 1, 25 do + table.insert(crafts, { + type = "Suffix", + group = "Candidate" .. index, + modTags = { "attribute" }, + types = { Ring = true }, + "+" .. index .. " to Strength", + }) + end + local calls = 0 + local maxCraftedLines = 0 + local evaluation = evaluate(makeRareRing(3, 2), crafts, function(args) + calls = calls + 1 + local craftedLines = 0 + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted then + craftedLines = craftedLines + 1 + end + end + maxCraftedLines = math.max(maxCraftedLines, craftedLines) + return { Life = 100 + craftedLines } + end) + + assert.are.equal(26, calls) + assert.are.equal(1, maxCraftedLines) + assert.is_truthy(evaluation.benchCraft) + end) + + it("keeps lower bench tiers when a higher tier has a worse trade-off", function() + local crafts = { + { + type = "Suffix", group = "FlaskTradeoff", level = 60, types = { Ring = true }, + "20% reduced Flask Charges gained", "(8-10)% increased Effect of Flasks on you", + }, + { + type = "Suffix", group = "FlaskTradeoff", level = 75, types = { Ring = true }, + "33% reduced Flask Charges gained", "(11-14)% increased Effect of Flasks on you", + }, + } + local evaluation = evaluate(makeRareRing(3, 2), crafts, function(args) + local life = 100 + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted and modLine.line:find("20% reduced", 1, true) then + life = 200 + elseif modLine.crafted and modLine.line:find("33% reduced", 1, true) then + life = 50 + end + end + return { Life = life } + end) + + assert.is_truthy(evaluation.benchCraft:find("20% reduced", 1, true)) + end) + + it("renders every line of a multi-line craft in the Ctrl preview", function() + local multiLineCraft = { + type = "Suffix", group = "FlaskTradeoff", types = { Ring = true }, + "20% reduced Flask Charges gained", "(8-10)% increased Effect of Flasks on you", + } + local originalItemString = makeRareRing(3, 2) + local evaluation = evaluate(originalItemString, { multiLineCraft }, function(args) + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted then + return { Life = 200 } + end + end + return { Life = 100 } + end) + local tooltipQuery = new("TradeQuery", { itemsTab = { } }) + tooltipQuery.itemsTab.activeItemSet = { } + tooltipQuery.itemsTab.slots = { } + tooltipQuery.slotTables[1] = { slotName = "Ring 1" } + tooltipQuery.resultTbl[1] = { { + item_string = originalItemString, + amount = 1, + currency = "chaos", + evaluation = { evaluation }, + } } + tooltipQuery.sortedResultTbl[1] = { { index = 1 } } + tooltipQuery.itemsTab.AddItemTooltip = function(_, tooltip, item) + for _, modLine in ipairs(item.explicitModLines or { }) do + local renderedLine = modLine.range + and itemLib.applyRange(modLine.line, modLine.range, modLine.valueScalar, modLine.corruptedRange) + or modLine.line + tooltip:AddLine(16, colorCodes.MAGIC .. renderedLine, nil, modLine) + end + end + tooltipQuery.IsBenchCraftPreviewActive = function() return true end + tooltipQuery:PriceItemRowDisplay(1, nil, 0, 20) + local tooltip = new("Tooltip") + + tooltipQuery.controls.resultDropdown1.tooltipFunc(tooltip, "DROP", 1, nil) + + assert.are.equal(originalItemString, tooltipQuery.resultTbl[1][1].item_string) + assert.are.equal(1, #tooltip.childTooltips) + local previewText = "" + for _, line in ipairs(tooltip.childTooltips[1].lines) do + previewText = previewText .. (line.text or "") .. "\n" + end + assert.is_truthy(previewText:find("[Craft] 20% reduced Flask Charges gained", 1, true)) + assert.is_truthy(previewText:find("9% increased Effect of Flasks on you", 1, true)) + end) + + end) end) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 7ea2fcedf00..6adbe9019e9 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -781,6 +781,10 @@ function TradeQueryClass:SetNotice(notice_control, msg) notice_control.label = msg end +function TradeQueryClass:IsBenchCraftPreviewActive() + return IsKeyDown("CTRL") +end + -- Method to reduce the full output to only the values that were 'weighted' function TradeQueryClass:ReduceOutput(output) local smallOutput = {} @@ -795,6 +799,196 @@ function TradeQueryClass:ReduceOutput(output) return smallOutput end +local function getDefaultAffixSideLimit(item) + if item.rarity == "MAGIC" then + return 1 + elseif item.rarity == "RARE" then + return (item.type == "Jewel" or item.type == "Graft") and 2 or 3 + end +end + +local function getAffixSideLimit(item, side, defaultLimit) + local affixes = item[side] + if item.crafted and item.affixLimit and item.affixLimit > 0 then + return affixes.limit or item.affixLimit / 2 + end + return m_max(defaultLimit + (affixes.limit or 0), 0) +end + +local function normaliseBenchCraftLine(line) + return line:lower() + :gsub("{[^}]+}", "") + :gsub("[%d#%(%)%+%-%.]", "") + :gsub("%s+", " ") + :match("^%s*(.-)%s*$") +end + +function TradeQueryClass:GetBenchCraftAvailability(item) + if item.corrupted or item.mirrored or item.rareLikeUnique then + return + end + local defaultLimit = getDefaultAffixSideLimit(item) + if not defaultLimit then + return + end + local explicitModLines = item.explicitModLines or { } + if #explicitModLines == 0 then + return + end + local occupied = { Prefix = 0, Suffix = 0 } + local craftedCount = 0 + local craftedLimit = 1 + local seenAffixes = { } + local seenCraftedAffixes = { } + for _, modLine in ipairs(explicitModLines) do + local side = modLine.prefix and "Prefix" or modLine.suffix and "Suffix" or nil + if not side then + return + end + local affixKeys = { modLine } + if modLine.modGroup and modLine.modGroup:sub(1, 6) == "trade:" then + affixKeys = { } + for affixId in modLine.modGroup:sub(7):gmatch("[^|]+") do + t_insert(affixKeys, "trade:" .. affixId) + end + end + for _, affixKey in ipairs(affixKeys) do + if seenAffixes[affixKey] and seenAffixes[affixKey] ~= side then + return + elseif not seenAffixes[affixKey] then + seenAffixes[affixKey] = side + occupied[side] = occupied[side] + 1 + end + if modLine.crafted and not seenCraftedAffixes[affixKey] then + seenCraftedAffixes[affixKey] = true + craftedCount = craftedCount + 1 + end + end + if modLine.crafted then + if modLine.line:find("Can have up to 3 Crafted Modifiers", 1, true) then + craftedLimit = 3 + end + end + end + if craftedCount >= craftedLimit then + return + end + return { + Prefix = m_max(getAffixSideLimit(item, "prefixes", defaultLimit) - occupied.Prefix, 0), + Suffix = m_max(getAffixSideLimit(item, "suffixes", defaultLimit) - occupied.Suffix, 0), + } +end + +local function getExistingAffixGroups(item, existingLines) + local groups = { } + for _, side in ipairs({ "prefixes", "suffixes" }) do + for _, affix in ipairs(item[side] or { }) do + local mod = item.affixes and item.affixes[affix.modId] + if mod and mod.group then + groups[mod.group] = true + end + end + end + for _, mod in pairs(item.affixes or { }) do + if mod.group then + for _, line in ipairs(mod) do + if existingLines[normaliseBenchCraftLine(line)] then + groups[mod.group] = true + break + end + end + end + end + return groups +end + +local function getExistingModLines(item) + local lines = { } + for _, modLine in ipairs(item.explicitModLines or { }) do + for line in modLine.line:gmatch("[^\r\n]+") do + lines[normaliseBenchCraftLine(line)] = true + end + end + return lines +end + +local function conflictsWithExistingAffix(craft, existingGroups, existingLines) + if craft.group and existingGroups[craft.group] then + return true + end + for _, line in ipairs(craft) do + if existingLines[normaliseBenchCraftLine(line)] then + return true + end + end + return false +end + +function TradeQueryClass:GetBestBenchCraftEvaluation(item, slotName, calcFunc, baseOutput, output, weight) + local available = self:GetBenchCraftAvailability(item) + if not available or (available.Prefix == 0 and available.Suffix == 0) then + return output, weight + end + local existingLines = getExistingModLines(item) + local existingGroups = getExistingAffixGroups(item, existingLines) + local bestCraft + local bestCraftItemString + local bestCraftLineIndexes + local originalItem = item:BuildRaw() + local craftedItem = new("Item", originalItem) + local requiresFullParse = #craftedItem.modMagnitudeMods > 0 or (craftedItem.catalyst and craftedItem.catalyst > 0) + for _, craft in ipairs(self.itemsTab.build.data.masterMods or { }) do + if available[craft.type] and available[craft.type] > 0 + and craft.types and craft.types[item.type] + and not conflictsWithExistingAffix(craft, existingGroups, existingLines) then + local firstCraftLineIndex = #craftedItem.explicitModLines + 1 + for _, line in ipairs(craft) do + local modList, extra + if not requiresFullParse then + local rangedLine = itemLib.applyRange(line, main.defaultItemAffixQuality or 0.5, 1, 1) + modList, extra = modLib.parseMod(rangedLine) + end + t_insert(craftedItem.explicitModLines, { + line = line, + modList = modList, + extra = extra, + range = main.defaultItemAffixQuality or 0.5, + modTags = craft.modTags, + modGroup = craft.group, + crafted = true, + prefix = craft.type == "Prefix", + suffix = craft.type == "Suffix", + }) + end + if requiresFullParse then + craftedItem:BuildAndParseRaw() + else + craftedItem:BuildModList() + end + local craftOutput = self:ReduceOutput(calcFunc({ repSlotName = slotName, repItem = craftedItem })) + local craftWeight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, craftOutput, self.statSortSelectionList) + if craftWeight > weight then + output = craftOutput + weight = craftWeight + bestCraft = table.concat(craft, "/") .. " ^8(" .. craft.type .. ")" + bestCraftItemString = craftedItem:BuildRaw() + bestCraftLineIndexes = { } + for lineIndex = firstCraftLineIndex, #craftedItem.explicitModLines do + t_insert(bestCraftLineIndexes, lineIndex) + end + end + if requiresFullParse then + craftedItem = new("Item", originalItem) + else + for _ = 1, #craft do + t_remove(craftedItem.explicitModLines, #craftedItem.explicitModLines) + end + end + end + end + return output, weight, bestCraft, bestCraftItemString, bestCraftLineIndexes +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] @@ -843,7 +1037,17 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba local output = self:ReduceOutput(calcFunc({ repSlotName = slotName, repItem = item })) local weight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, output, self.statSortSelectionList) - result.evaluation = {{ output = output, weight = weight }} + local benchCraft, benchCraftItemString, benchCraftLineIndexes + if slotTbl.considerBenchCraft then + output, weight, benchCraft, benchCraftItemString, benchCraftLineIndexes = self:GetBestBenchCraftEvaluation(item, slotName, calcFunc, baseOutput, output, weight) + end + result.evaluation = {{ + output = output, + weight = weight, + benchCraft = benchCraft, + benchCraftItemString = benchCraftItemString, + benchCraftLineIndexes = benchCraftLineIndexes, + }} end return result.evaluation end @@ -1191,6 +1395,41 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite self.itemsTab.build:AddStatComparesToTooltip(tooltip, self.onlyWeightedBaseOutput[row_idx][result_index], evaluationEntry.output, "^8Allocating ^7"..nodeCombo.."^8 will give You:", #nodeDNs + 2) end end + local function addBenchCraftToTooltipIfApplicable(tooltip, result) + local evaluation = result.evaluation and result.evaluation[1] + if not evaluation or not evaluation.benchCraft then + return + end + local compareHint = evaluation.benchCraftItemString and colorCodes.TIP .. " [Ctrl: compare]" or "" + tooltip:AddSeparator(10) + tooltip:AddLine(16, "^7Bench craft: " .. evaluation.benchCraft .. compareHint) + return evaluation + end + local function addBenchCraftPreviewIfApplicable(tooltip, evaluation, tooltipSlot) + if not evaluation or not evaluation.benchCraftItemString or not self:IsBenchCraftPreviewActive() then + return + end + local previewItem = new("Item", evaluation.benchCraftItemString) + local previewTooltip = tooltip.benchCraftPreviewTooltip or new("Tooltip") + tooltip.benchCraftPreviewTooltip = previewTooltip + previewTooltip:Clear() + self.itemsTab:AddItemTooltip(previewTooltip, previewItem, tooltipSlot) + local craftedModLines = { } + for _, lineIndex in ipairs(evaluation.benchCraftLineIndexes or { }) do + local modLine = previewItem.explicitModLines[lineIndex] + if modLine then + craftedModLines[modLine] = true + end + end + for _, line in ipairs(previewTooltip.lines) do + if line.modLine and craftedModLines[line.modLine] and line.text then + line.text = colorCodes.WARNING .. "[Craft] " .. StripEscapes(line.text) + end + end + previewTooltip:AddSeparator(10) + previewTooltip:AddLine(14, colorCodes.TIP .. "Estimated with bench craft.") + tooltip.childTooltips = { previewTooltip } + end controls["resultDropdown"..row_idx].tooltipFunc = function(tooltip, dropdown_mode, dropdown_index, dropdown_display_string) local sortedRow = self.sortedResultTbl[row_idx] if not sortedRow or not sortedRow[dropdown_index] then @@ -1203,9 +1442,12 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end local item = new("Item"):Item(result.item_string) tooltip:Clear() + tooltip.childTooltips = nil local tooltipSlot = slotTbl.selectedJewelNodeId and self.itemsTab.sockets[slotTbl.selectedJewelNodeId] or activeSlot self.itemsTab:AddItemTooltip(tooltip, item, tooltipSlot) addMegalomaniacCompareToTooltipIfApplicable(tooltip, pb_index) + local benchCraftEvaluation = addBenchCraftToTooltipIfApplicable(tooltip, result) + addBenchCraftPreviewIfApplicable(tooltip, benchCraftEvaluation, tooltipSlot) tooltip:AddSeparator(10) tooltip:AddLine(16, string.format("^7Price: %s %s", result.amount, result.currency)) end diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 9b7e3f5b406..31af84c4f10 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1184,6 +1184,16 @@ function TradeQueryGeneratorClass:RequestQuery(slot, context, statWeights, callb updateLastAnchor(controls.includeMirrored) end + local supportsBenchCraft = slot and not context.slotTbl.unique and not isJewelSlot and not isAbyssalJewelSlot + and not slot.slotName:find("Flask") + if supportsBenchCraft then + controls.considerBenchCraft = new("CheckBoxControl", { "TOPRIGHT", lastItemAnchor, "BOTTOMRIGHT" }, + { 0, 5, 18 }, "Empty Mods:", function(state) end, + "Values an empty prefix or suffix using its best bench craft.") + controls.considerBenchCraft.state = self.lastConsiderBenchCraft == true + updateLastAnchor(controls.considerBenchCraft) + end + if not isJewelSlot and not isAbyssalJewelSlot and includeScourge then controls.includeScourge = new("CheckBoxControl"):CheckBoxControl({ "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 18 }, "Scourge Mods:", function(state) end) controls.includeScourge.state = (self.lastIncludeScourge == nil or self.lastIncludeScourge == true) @@ -1348,6 +1358,8 @@ Remove: %s will be removed from the search results.]], term, term, term) if controls.includeMirrored then self.lastIncludeMirrored, options.includeMirrored = controls.includeMirrored.state, controls.includeMirrored.state end + self.lastConsiderBenchCraft = controls.considerBenchCraft and controls.considerBenchCraft.state or false + context.slotTbl.considerBenchCraft = self.lastConsiderBenchCraft if controls.includeCorrupted then self.lastIncludeCorrupted, options.includeCorrupted = controls.includeCorrupted.state, controls.includeCorrupted.state end diff --git a/src/Classes/TradeQueryRequests.lua b/src/Classes/TradeQueryRequests.lua index b0067123807..cab9d242851 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -337,13 +337,73 @@ function TradeQueryRequestsClass:FetchResultBlock(url, callback) end end - local function processLine(modLine) + local groupsByDomain = { } + for _, domain in ipairs({ "explicit", "crafted" }) do + local groupsByHash = { } + for _, entry in ipairs(item.extended and item.extended.hashes and item.extended.hashes[domain] or { }) do + if type(entry) == "table" and type(entry[1]) == "string" and type(entry[2]) == "table" then + if groupsByHash[entry[1]] ~= nil then + groupsByHash[entry[1]] = false + else + groupsByHash[entry[1]] = entry[2] + end + end + end + groupsByDomain[domain] = groupsByHash + end + + local function getTradeAffixMetadata(modLine) + -- ItemMod flags do not include affix sides; source-mod tiers use P/S. + -- Extended hash indices identify the source affix across multi-line stats. + local affixSide + local mods = type(modLine.mods) == "table" and modLine.mods or { } + for _, mod in ipairs(mods) do + local side = mod.tier and mod.tier:sub(1, 1) + if side ~= "P" and side ~= "S" then + return + elseif affixSide and affixSide ~= side then + return + end + affixSide = side + end + local domain = modLine.domain + local uniqueMod = #mods == 1 and mods[1] or nil + local magnitude = uniqueMod and uniqueMod.magnitudes and uniqueMod.magnitudes[1] + local rawHash = modLine.hash or uniqueMod and uniqueMod.hash or magnitude and magnitude.hash + local hash = type(rawHash) == "string" and rawHash:gsub("^stat%.", "") + local groupIndices = groupsByDomain[domain] and groupsByDomain[domain][hash] + if not affixSide or type(groupIndices) ~= "table" or #groupIndices == 0 then + return + end + local affixIds = { } + local seenIndices = { } + for _, index in ipairs(groupIndices) do + if type(index) ~= "number" or seenIndices[index] then + return + end + seenIndices[index] = true + t_insert(affixIds, domain .. ":" .. index) + end + table.sort(affixIds) + return affixSide == "P" and "prefix" or "suffix", "trade:" .. table.concat(affixIds, "|") + end + + local function processLine(modLine, includeAffixMetadata) local s = "" for flagName, flag in pairs(modLine.flags or {}) do if flag then s = s .. string.format("{%s}", flagName) end end + if modLine.domain == "crafted" and not (modLine.flags and modLine.flags.crafted) then + s = s .. "{crafted}" + end + if includeAffixMetadata then + local affixSide, affixGroup = getTradeAffixMetadata(modLine) + if affixSide then + s = s .. string.format("{%s}{modGroup:%s}", affixSide, affixGroup) + end + end return s .. escapeGGGString(modLine.description) end t_insert(rawLines, "Implicits: " .. (#item.enchantMods + #item.scourgeMods + #item.implicitMods)) @@ -357,7 +417,7 @@ function TradeQueryRequestsClass:FetchResultBlock(url, callback) t_insert(rawLines, processLine(modLine)) end for _, modLine in ipairs(item.explicitMods) do - t_insert(rawLines, processLine(modLine)) + t_insert(rawLines, processLine(modLine, true)) end if item.duplicated then t_insert(rawLines, "Mirrored") From a077e204aa5ba8a56eb53d4051f5ed72c382e109 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 16:11:32 +0200 Subject: [PATCH 2/8] Clarify Empty Mods result sorting Make the tooltip explicit that bench craft potential sorts fetched results locally. Align the bench craft tests with the same evaluation vocabulary and the existing flask modifier group name. --- spec/System/TestTradeQuery_spec.lua | 26 +++++++++++++------------- src/Classes/TradeQueryGenerator.lua | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 93501e6335d..79c58458244 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -247,7 +247,7 @@ describe("TradeQuery", function() return tradeQuery:GetResultEvaluation(1, 1, calcOverride or calc, { Life = 100 })[1] end - it("only scores suffix crafts when the prefix side is full", function() + it("only evaluates suffix crafts when the prefix side is full", function() local evaluation = evaluate(makeRareRing(3, 2)) assert.are.equal(1.5, evaluation.weight) @@ -256,19 +256,19 @@ describe("TradeQuery", function() assert.are.same({ 6 }, evaluation.benchCraftLineIndexes) end) - it("only scores prefix crafts when the suffix side is full", function() + it("only evaluates prefix crafts when the suffix side is full", function() local evaluation = evaluate(makeRareRing(2, 3)) assert.is_true(evaluation.weight > 1) assert.is_truthy(evaluation.benchCraft:find("maximum Life", 1, true)) end) - it("previews the same craft roll that was used for scoring", function() - local scoredCraftLine + it("previews the same craft roll that was used for evaluation", function() + local evaluatedCraftLine local evaluation = evaluate(makeRareRing(3, 2), { suffixCraft }, function(args) for _, modLine in ipairs(args.repItem.explicitModLines or { }) do if modLine.crafted then - scoredCraftLine = itemLib.applyRange(modLine.line, modLine.range, 1, 1) + evaluatedCraftLine = itemLib.applyRange(modLine.line, modLine.range, 1, 1) return { Life = 200 } end end @@ -278,7 +278,7 @@ describe("TradeQuery", function() local previewModLine = previewItem.explicitModLines[evaluation.benchCraftLineIndexes[1]] local previewCraftLine = itemLib.applyRange(previewModLine.line, previewModLine.range, 1, 1) - assert.are.equal(scoredCraftLine, previewCraftLine) + assert.are.equal(evaluatedCraftLine, previewCraftLine) assert.are.equal(main.defaultItemAffixQuality or 0.5, previewModLine.range) end) @@ -307,7 +307,7 @@ describe("TradeQuery", function() assert.is_nil(evaluation.benchCraft) end) - it("does not score crafts on corrupted or mirrored items", function() + it("does not evaluate crafts on corrupted or mirrored items", function() for _, marker in ipairs({ "Corrupted", "Mirrored" }) do local evaluation = evaluate(makeRareRing(1, 1, { marker })) assert.are.equal(1, evaluation.weight) @@ -322,21 +322,21 @@ describe("TradeQuery", function() assert.is_nil(evaluation.benchCraft) end) - it("does not score an item when an explicit affix side is unknown", function() + it("does not evaluate an item when an explicit affix side is unknown", function() local evaluation = evaluate(makeRareRing(0, 0, { "+50 to maximum Life" }), { suffixCraft }) assert.are.equal(1, evaluation.weight) assert.is_nil(evaluation.benchCraft) end) - it("does not score an item without explicit affix metadata", function() + it("does not evaluate an item without explicit affix metadata", function() local evaluation = evaluate(makeRareRing(0, 0), { suffixCraft }) assert.are.equal(1, evaluation.weight) assert.is_nil(evaluation.benchCraft) end) - it("does not score contradictory sides for the same trade affix", function() + it("does not evaluate contradictory sides for the same trade affix", function() local evaluation = evaluate(makeRareRing(0, 0, { "{prefix}{modGroup:trade:explicit:0}+50 to maximum Life", "{suffix}{modGroup:trade:explicit:0}+30% to Fire Resistance", @@ -391,11 +391,11 @@ describe("TradeQuery", function() it("keeps lower bench tiers when a higher tier has a worse trade-off", function() local crafts = { { - type = "Suffix", group = "FlaskTradeoff", level = 60, types = { Ring = true }, + type = "Suffix", group = "FlaskEffectAndFlaskChargesGained", level = 60, types = { Ring = true }, "20% reduced Flask Charges gained", "(8-10)% increased Effect of Flasks on you", }, { - type = "Suffix", group = "FlaskTradeoff", level = 75, types = { Ring = true }, + type = "Suffix", group = "FlaskEffectAndFlaskChargesGained", level = 75, types = { Ring = true }, "33% reduced Flask Charges gained", "(11-14)% increased Effect of Flasks on you", }, } @@ -416,7 +416,7 @@ describe("TradeQuery", function() it("renders every line of a multi-line craft in the Ctrl preview", function() local multiLineCraft = { - type = "Suffix", group = "FlaskTradeoff", types = { Ring = true }, + type = "Suffix", group = "FlaskEffectAndFlaskChargesGained", types = { Ring = true }, "20% reduced Flask Charges gained", "(8-10)% increased Effect of Flasks on you", } local originalItemString = makeRareRing(3, 2) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 31af84c4f10..efa9015182e 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1189,7 +1189,7 @@ function TradeQueryGeneratorClass:RequestQuery(slot, context, statWeights, callb if supportsBenchCraft then controls.considerBenchCraft = new("CheckBoxControl", { "TOPRIGHT", lastItemAnchor, "BOTTOMRIGHT" }, { 0, 5, 18 }, "Empty Mods:", function(state) end, - "Values an empty prefix or suffix using its best bench craft.") + "Sorts fetched results using their best possible bench craft.") controls.considerBenchCraft.state = self.lastConsiderBenchCraft == true updateLastAnchor(controls.considerBenchCraft) end From d03a259192a4bfb8c02c6a4c658b1e94375a9607 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 17:56:19 +0200 Subject: [PATCH 3/8] Use standard terminology in Trade tests --- spec/System/TestTradeQuery_spec.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 79c58458244..c94d031c24c 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -288,7 +288,7 @@ describe("TradeQuery", function() assert.is_nil(evaluation.benchCraft) end) - it("allows another bench craft when the item has the multimod modifier", function() + it("allows another bench craft with multiple crafted modifiers", function() local evaluation = evaluate(makeRareRing(1, 1, { "{crafted}{suffix}Can have up to 3 Crafted Modifiers", }), { prefixCraft }) @@ -296,7 +296,7 @@ describe("TradeQuery", function() assert.is_truthy(evaluation.benchCraft:find("maximum Life", 1, true)) end) - it("does not add a fourth craft when multimod affixes have distinct source indices", function() + it("does not add a fourth craft when crafted modifiers have distinct source indices", function() local evaluation = evaluate(makeRareRing(1, 0, { "{crafted}{suffix}{modGroup:trade:crafted:0}Can have up to 3 Crafted Modifiers", "{crafted}{suffix}{modGroup:trade:crafted:1}+20% to Fire Resistance", From a47ae3dcd34c80acd75cea91b8308042b8da07c3 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 18:09:28 +0200 Subject: [PATCH 4/8] Cover bench craft item type restrictions --- spec/System/TestTradeQuery_spec.lua | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index c94d031c24c..68e1bbbddce 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -263,6 +263,20 @@ describe("TradeQuery", function() assert.is_truthy(evaluation.benchCraft:find("maximum Life", 1, true)) end) + it("does not evaluate crafts unavailable for the item type", function() + local amuletCraft = { + type = "Prefix", + group = "IncreasedLife", + modTags = { "life" }, + types = { Amulet = true }, + "+(51-55) to maximum Life", + } + local evaluation = evaluate(makeRareRing(2, 2), { amuletCraft }) + + assert.are.equal(1, evaluation.weight) + assert.is_nil(evaluation.benchCraft) + end) + it("previews the same craft roll that was used for evaluation", function() local evaluatedCraftLine local evaluation = evaluate(makeRareRing(3, 2), { suffixCraft }, function(args) From ae57e028766a7af419d851007848630d0a9f6000 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 18:51:57 +0200 Subject: [PATCH 5/8] Evaluate bench craft replacements in Trader Allow a single standard crafted modifier to be removed in the simulation before testing legal replacements. Multicraft items continue to support additions only. --- spec/System/TestTradeQuery_spec.lua | 66 +++++++++++++++++++++++++++- src/Classes/TradeQuery.lua | 67 ++++++++++++++++++++++++----- src/Classes/TradeQueryGenerator.lua | 4 +- 3 files changed, 122 insertions(+), 15 deletions(-) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 68e1bbbddce..7e95267ee3f 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -88,6 +88,34 @@ describe("TradeQuery", function() assert.is_truthy(tooltipText:find("[Ctrl: compare]", 1, true)) end) + it("identifies a replaced bench craft in the result tooltip", function() + local tq = newTradeQuery({ + resultTbl = { [1] = { [1] = { + item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring", + amount = 1, + currency = "chaos", + evaluation = { { + benchCraft = "+25 to Strength ^8(Suffix)", + benchCraftReplaced = "+20 to Dexterity ^8(Suffix)", + benchCraftItemString = "Rarity: RARE\nBehemoth Hold\nGold Ring\nImplicits: 0\n{crafted}{suffix}+25 to Strength", + } }, + } } }, + sortedResultTbl = { [1] = { { index = 1 } } }, + }) + tq.itemsTab.AddItemTooltip = function() end + local dropdown = buildRow1Dropdown(tq) + local tooltip = new("Tooltip") + + dropdown.tooltipFunc(tooltip, "DROP", 1, nil) + + local tooltipText = "" + for _, line in ipairs(tooltip.lines) do + tooltipText = tooltipText .. (line.text or "") .. "\n" + end + assert.is_truthy(tooltipText:find("Replace craft: +20 to Dexterity", 1, true)) + assert.is_truthy(tooltipText:find("-> +25 to Strength", 1, true)) + end) + it("shows the simulated item and highlights its craft while Ctrl is held", function() local tq = newTradeQuery({ resultTbl = { [1] = { [1] = { @@ -296,10 +324,43 @@ describe("TradeQuery", function() assert.are.equal(main.defaultItemAffixQuality or 0.5, previewModLine.range) end) - it("does not add a second bench craft", function() - local evaluation = evaluate(makeRareRing(1, 1, { "{crafted}{suffix}+20 to Strength" })) + it("replaces an existing bench craft when the item is otherwise full", function() + local evaluation = evaluate(makeRareRing(3, 2, { "{crafted}{suffix}+20 to Dexterity" }), { suffixCraft }) + + assert.is_truthy(evaluation.benchCraft:find("to Strength", 1, true)) + assert.is_truthy(evaluation.benchCraftReplaced:find("+20 to Dexterity", 1, true)) + assert.is_nil(evaluation.benchCraftItemString:find("+20 to Dexterity", 1, true)) + end) + + it("keeps an existing bench craft when every replacement is worse", function() + local evaluation = evaluate(makeRareRing(1, 1, { "{crafted}{prefix}+50 to maximum Life" }), { suffixCraft }) + assert.is_true(evaluation.weight > 1) assert.is_nil(evaluation.benchCraft) + assert.is_nil(evaluation.benchCraftReplaced) + end) + + it("removes every line of a replaced multi-line craft", function() + local evaluation = evaluate(makeRareRing(1, 1, { + "{crafted}{prefix}{modGroup:trade:crafted:0}+20 to Dexterity", + "{crafted}{prefix}{modGroup:trade:crafted:0}10% increased Rarity of Items found", + }), { suffixCraft }) + + assert.is_truthy(evaluation.benchCraftReplaced:find("to Dexterity/10% increased Rarity", 1, true)) + assert.is_nil(evaluation.benchCraftItemString:find("+20 to Dexterity", 1, true)) + assert.is_nil(evaluation.benchCraftItemString:find("10% increased Rarity", 1, true)) + end) + + it("tracks the replacement preview line after a full item reparse", function() + local itemString = makeRareRing(3, 2, { "{crafted}{suffix}+20 to Dexterity" }) + :gsub("Implicits: 0", "Catalyst: Intrinsic\nCatalystQuality: 20\nImplicits: 0") + local evaluation = evaluate(itemString, { suffixCraft }) + local previewItem = new("Item", evaluation.benchCraftItemString) + local previewModLine = previewItem.explicitModLines[evaluation.benchCraftLineIndexes[1]] + + assert.is_true(previewModLine.crafted) + assert.is_truthy(previewModLine.line:find("to Strength", 1, true)) + assert.is_nil(evaluation.benchCraftItemString:find("+20 to Dexterity", 1, true)) end) it("allows another bench craft with multiple crafted modifiers", function() @@ -308,6 +369,7 @@ describe("TradeQuery", function() }), { prefixCraft }) assert.is_truthy(evaluation.benchCraft:find("maximum Life", 1, true)) + assert.is_nil(evaluation.benchCraftReplaced) end) it("does not add a fourth craft when crafted modifiers have distinct source indices", function() diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 6adbe9019e9..890cbec668c 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -870,13 +870,17 @@ function TradeQueryClass:GetBenchCraftAvailability(item) end end end + local craftState = { + count = craftedCount, + limit = craftedLimit, + } if craftedCount >= craftedLimit then - return + return nil, craftState end return { Prefix = m_max(getAffixSideLimit(item, "prefixes", defaultLimit) - occupied.Prefix, 0), Suffix = m_max(getAffixSideLimit(item, "suffixes", defaultLimit) - occupied.Suffix, 0), - } + }, craftState end local function getExistingAffixGroups(item, existingLines) @@ -924,22 +928,58 @@ local function conflictsWithExistingAffix(craft, existingGroups, existingLines) return false end +local function getItemWithoutCraftedMods(item) + local strippedItem = new("Item", item:BuildRaw()) + local retainedModLines = { } + local replacedCraftLines = { } + local replacedCraftType + for _, modLine in ipairs(strippedItem.explicitModLines or { }) do + if modLine.crafted then + for line in modLine.line:gmatch("[^\r\n]+") do + t_insert(replacedCraftLines, line) + end + replacedCraftType = replacedCraftType or (modLine.prefix and "Prefix" or modLine.suffix and "Suffix") + else + t_insert(retainedModLines, modLine) + end + end + if #replacedCraftLines == 0 then + return + end + strippedItem.explicitModLines = retainedModLines + local replacedCraft = table.concat(replacedCraftLines, "/") + if replacedCraftType then + replacedCraft = replacedCraft .. " ^8(" .. replacedCraftType .. ")" + end + return new("Item", strippedItem:BuildRaw()), replacedCraft +end + function TradeQueryClass:GetBestBenchCraftEvaluation(item, slotName, calcFunc, baseOutput, output, weight) - local available = self:GetBenchCraftAvailability(item) + local available, craftState = self:GetBenchCraftAvailability(item) + local evaluationItem = item + local replacedCraft + if (not available or (available.Prefix == 0 and available.Suffix == 0)) + and craftState and craftState.count == 1 and craftState.limit == 1 then + evaluationItem, replacedCraft = getItemWithoutCraftedMods(item) + if evaluationItem then + available = self:GetBenchCraftAvailability(evaluationItem) + end + end if not available or (available.Prefix == 0 and available.Suffix == 0) then return output, weight end - local existingLines = getExistingModLines(item) - local existingGroups = getExistingAffixGroups(item, existingLines) + local existingLines = getExistingModLines(evaluationItem) + local existingGroups = getExistingAffixGroups(evaluationItem, existingLines) local bestCraft local bestCraftItemString local bestCraftLineIndexes - local originalItem = item:BuildRaw() + local bestReplacedCraft + local originalItem = evaluationItem:BuildRaw() local craftedItem = new("Item", originalItem) local requiresFullParse = #craftedItem.modMagnitudeMods > 0 or (craftedItem.catalyst and craftedItem.catalyst > 0) for _, craft in ipairs(self.itemsTab.build.data.masterMods or { }) do if available[craft.type] and available[craft.type] > 0 - and craft.types and craft.types[item.type] + and craft.types and craft.types[evaluationItem.type] and not conflictsWithExistingAffix(craft, existingGroups, existingLines) then local firstCraftLineIndex = #craftedItem.explicitModLines + 1 for _, line in ipairs(craft) do @@ -972,6 +1012,7 @@ function TradeQueryClass:GetBestBenchCraftEvaluation(item, slotName, calcFunc, b weight = craftWeight bestCraft = table.concat(craft, "/") .. " ^8(" .. craft.type .. ")" bestCraftItemString = craftedItem:BuildRaw() + bestReplacedCraft = replacedCraft bestCraftLineIndexes = { } for lineIndex = firstCraftLineIndex, #craftedItem.explicitModLines do t_insert(bestCraftLineIndexes, lineIndex) @@ -986,7 +1027,7 @@ function TradeQueryClass:GetBestBenchCraftEvaluation(item, slotName, calcFunc, b end end end - return output, weight, bestCraft, bestCraftItemString, bestCraftLineIndexes + return output, weight, bestCraft, bestCraftItemString, bestCraftLineIndexes, bestReplacedCraft end -- Method to evaluate a result by getting it's output and weight @@ -1037,9 +1078,9 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba local output = self:ReduceOutput(calcFunc({ repSlotName = slotName, repItem = item })) local weight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, output, self.statSortSelectionList) - local benchCraft, benchCraftItemString, benchCraftLineIndexes + local benchCraft, benchCraftItemString, benchCraftLineIndexes, benchCraftReplaced if slotTbl.considerBenchCraft then - output, weight, benchCraft, benchCraftItemString, benchCraftLineIndexes = self:GetBestBenchCraftEvaluation(item, slotName, calcFunc, baseOutput, output, weight) + output, weight, benchCraft, benchCraftItemString, benchCraftLineIndexes, benchCraftReplaced = self:GetBestBenchCraftEvaluation(item, slotName, calcFunc, baseOutput, output, weight) end result.evaluation = {{ output = output, @@ -1047,6 +1088,7 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba benchCraft = benchCraft, benchCraftItemString = benchCraftItemString, benchCraftLineIndexes = benchCraftLineIndexes, + benchCraftReplaced = benchCraftReplaced, }} end return result.evaluation @@ -1401,8 +1443,11 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite return end local compareHint = evaluation.benchCraftItemString and colorCodes.TIP .. " [Ctrl: compare]" or "" + local craftLabel = evaluation.benchCraftReplaced + and "^7Replace craft: " .. evaluation.benchCraftReplaced .. " -> " + or "^7Bench craft: " tooltip:AddSeparator(10) - tooltip:AddLine(16, "^7Bench craft: " .. evaluation.benchCraft .. compareHint) + tooltip:AddLine(16, craftLabel .. evaluation.benchCraft .. compareHint) return evaluation end local function addBenchCraftPreviewIfApplicable(tooltip, evaluation, tooltipSlot) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index efa9015182e..f562c89bf47 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1188,8 +1188,8 @@ function TradeQueryGeneratorClass:RequestQuery(slot, context, statWeights, callb and not slot.slotName:find("Flask") if supportsBenchCraft then controls.considerBenchCraft = new("CheckBoxControl", { "TOPRIGHT", lastItemAnchor, "BOTTOMRIGHT" }, - { 0, 5, 18 }, "Empty Mods:", function(state) end, - "Sorts fetched results using their best possible bench craft.") + { 0, 5, 18 }, "Bench Craft:", function(state) end, + "Sorts fetched results by their best bench craft or replacement.") controls.considerBenchCraft.state = self.lastConsiderBenchCraft == true updateLastAnchor(controls.considerBenchCraft) end From 8c76039c6a7eb2a95f087726764a79a8f490ae9a Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 10 Aug 2026 01:12:44 +0200 Subject: [PATCH 6/8] Bound bench craft result evaluation Attach immutable query weights to fetched results and use them to evaluate only the highest-weight legal bench craft. Keep an exact exhaustive fallback when weights are absent or stale. --- spec/System/TestTradeQueryGenerator_spec.lua | 38 ++++++ spec/System/TestTradeQuery_spec.lua | 125 ++++++++++++++++++- src/Classes/TradeQuery.lua | 81 ++++++++---- src/Classes/TradeQueryGenerator.lua | 66 +++++++++- 4 files changed, 284 insertions(+), 26 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index e11ea701b9b..1d557e113a9 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -153,6 +153,44 @@ describe("TradeQueryGenerator", function() end) end) + describe("EstimateBenchCraftWeight", function() + it("adds the weighted values of every craft line", function() + local queryGen = new("TradeQueryGenerator", { itemsTab = { } }) + queryGen.modData = { + Explicit = { + ["1203_TestAttributes"] = { tradeMod = { id = "explicit.stat_4080418644", text = "+# to Strength" } }, + ["1204_TestAttributes"] = { tradeMod = { id = "explicit.stat_3261801346", text = "+# to Dexterity" } }, + }, + } + queryGen.modWeights = { + { tradeModId = "explicit.stat_4080418644", weight = 2 }, + { tradeModId = "explicit.stat_3261801346", weight = 3 }, + } + local craft = { + "+(10-10) to Strength", + "+(20-20) to Dexterity", + statOrder = { 1203, 1204 }, + group = "TestAttributes", + } + local snapshot = queryGen:CreateBenchCraftWeightSnapshot({ { stat = "Life", weightMult = 1 } }) + + assert.are.equal(80, queryGen:EstimateBenchCraftWeight(craft, snapshot)) + end) + + it("keeps generated mod and stat weights immutable", function() + local queryGen = new("TradeQueryGenerator", { itemsTab = { } }) + queryGen.modWeights = { { tradeModId = "explicit.test", weight = 2 } } + local statWeights = { { stat = "Life", weightMult = 1 } } + + local snapshot = queryGen:CreateBenchCraftWeightSnapshot(statWeights) + queryGen.modWeights[1].weight = 20 + statWeights[1].weightMult = 10 + + assert.are.equal(2, snapshot.modWeights[1].weight) + assert.are.equal(1, snapshot.statWeights[1].weightMult) + end) + end) + describe("Filter prioritization", function() it("counts socket and link constraints against MAX_FILTERS", function() local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { items = {} } }) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 7e95267ee3f..2c224fac0ef 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -254,13 +254,16 @@ describe("TradeQuery", function() return table.concat(lines, "\n") end - local function evaluate(itemString, crafts, calcOverride) + local function evaluate(itemString, crafts, calcOverride, yieldFunc, weightSnapshot) local tradeQuery = new("TradeQuery", { itemsTab = { } }) tradeQuery.tradeQueryGenerator = mock_queryGen tradeQuery.itemsTab.build = { data = { masterMods = crafts or { prefixCraft, suffixCraft } } } tradeQuery.statSortSelectionList = { { stat = "Life", weightMult = 1 } } tradeQuery.slotTables[1] = { slotName = "Ring 1", considerBenchCraft = true } - tradeQuery.resultTbl[1] = { { item_string = itemString } } + tradeQuery.resultTbl[1] = { { + item_string = itemString, + benchCraftWeightSnapshot = weightSnapshot, + } } local function calc(args) local life = 100 for _, modLine in ipairs(args.repItem.explicitModLines or { }) do @@ -272,7 +275,7 @@ describe("TradeQuery", function() end return { Life = life } end - return tradeQuery:GetResultEvaluation(1, 1, calcOverride or calc, { Life = 100 })[1] + return tradeQuery:GetResultEvaluation(1, 1, calcOverride or calc, { Life = 100 }, yieldFunc)[1] end it("only evaluates suffix crafts when the prefix side is full", function() @@ -291,6 +294,31 @@ describe("TradeQuery", function() assert.is_truthy(evaluation.benchCraft:find("maximum Life", 1, true)) end) + it("reuses cached craft evaluations when a shared calculator is provided", function() + local tradeQuery = new("TradeQuery", { itemsTab = { } }) + tradeQuery.tradeQueryGenerator = mock_queryGen + tradeQuery.statSortSelectionList = { { stat = "Life", weightMult = 1 } } + tradeQuery.slotTables[1] = { slotName = "Ring 1", considerBenchCraft = true } + tradeQuery.resultTbl[1] = { { item_string = makeRareRing(3, 2) } } + local calls = 0 + tradeQuery.itemsTab.build = { data = { masterMods = { suffixCraft } } } + local function calc(args) + calls = calls + 1 + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted and modLine.line:find("to Strength", 1, true) then + return { Life = 150 } + end + end + return { Life = 100 } + end + local baseOutput = { Life = 100 } + + tradeQuery:GetResultEvaluation(1, 1, calc, baseOutput) + tradeQuery:GetResultEvaluation(1, 1, calc, baseOutput) + + assert.are.equal(2, calls) + end) + it("does not evaluate crafts unavailable for the item type", function() local amuletCraft = { type = "Prefix", @@ -464,6 +492,97 @@ describe("TradeQuery", function() assert.is_truthy(evaluation.benchCraft) end) + it("provides a cooperative yield point after the item and every bench craft", function() + local calls = 0 + local yields = 0 + + evaluate(makeRareRing(3, 2), { suffixCraft }, function() + calls = calls + 1 + return { Life = 100 } + end, function() + yields = yields + 1 + end) + + assert.are.equal(2, calls) + assert.are.equal(calls, yields) + end) + + it("fully evaluates only the highest-weight legal bench craft", function() + local predictedBest = { + type = "Suffix", + group = "PredictedBest", + types = { Ring = true }, + statOrder = { 1 }, + "+(1-1) to Strength", + } + local actualBest = { + type = "Suffix", + group = "ActualBest", + types = { Ring = true }, + statOrder = { 2 }, + "+(2-2) to Dexterity", + } + local weightSnapshot = { + modWeights = { { tradeModId = "explicit.test", weight = 1 } }, + statWeights = { { stat = "Life", weightMult = 1 } }, + } + local originalEstimator = mock_queryGen.EstimateBenchCraftWeight + mock_queryGen.EstimateBenchCraftWeight = function(_, craft, receivedSnapshot) + assert.are.equal(weightSnapshot, receivedSnapshot) + return craft == predictedBest and 2 or 1 + end + local calls = 0 + local evaluation = evaluate(makeRareRing(3, 2), { actualBest, predictedBest }, function(args) + calls = calls + 1 + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted and modLine.line:find("Dexterity", 1, true) then + return { Life = 300 } + elseif modLine.crafted and modLine.line:find("Strength", 1, true) then + return { Life = 200 } + end + end + return { Life = 100 } + end, nil, weightSnapshot) + mock_queryGen.EstimateBenchCraftWeight = originalEstimator + + assert.are.equal(2, calls) + assert.is_truthy(evaluation.benchCraft:find("Strength", 1, true)) + end) + + it("falls back to exhaustive evaluation when stat weights changed after the query", function() + local strengthCraft = { + type = "Suffix", group = "Strength", types = { Ring = true }, "+1 to Strength", + } + local dexterityCraft = { + type = "Suffix", group = "Dexterity", types = { Ring = true }, "+2 to Dexterity", + } + local staleSnapshot = { + modWeights = { { tradeModId = "explicit.test", weight = 1 } }, + statWeights = { { stat = "Life", weightMult = 2 } }, + } + local originalEstimator = mock_queryGen.EstimateBenchCraftWeight + mock_queryGen.EstimateBenchCraftWeight = function(_, _, receivedSnapshot) + assert.is_nil(receivedSnapshot) + end + local calls = 0 + local evaluation = evaluate(makeRareRing(3, 2), { strengthCraft, dexterityCraft }, function(args) + calls = calls + 1 + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted and modLine.line:find("Dexterity", 1, true) then + return { Life = 190 } + elseif modLine.crafted and modLine.line:find("Strength", 1, true) then + return { Life = 150 } + end + end + return { Life = 100 } + end, nil, staleSnapshot) + mock_queryGen.EstimateBenchCraftWeight = originalEstimator + + assert.are.equal(3, calls) + assert.are.equal(190, evaluation.output.Life) + assert.is_truthy(evaluation.benchCraft:find("Dexterity", 1, true)) + end) + it("keeps lower bench tiers when a higher tier has a worse trade-off", function() local crafts = { { diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 890cbec668c..0106d46ab23 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -954,7 +954,7 @@ local function getItemWithoutCraftedMods(item) return new("Item", strippedItem:BuildRaw()), replacedCraft end -function TradeQueryClass:GetBestBenchCraftEvaluation(item, slotName, calcFunc, baseOutput, output, weight) +function TradeQueryClass:GetBestBenchCraftEvaluation(item, slotName, calcFunc, baseOutput, output, weight, weightSnapshot, yieldFunc) local available, craftState = self:GetBenchCraftAvailability(item) local evaluationItem = item local replacedCraft @@ -977,10 +977,27 @@ function TradeQueryClass:GetBestBenchCraftEvaluation(item, slotName, calcFunc, b local originalItem = evaluationItem:BuildRaw() local craftedItem = new("Item", originalItem) local requiresFullParse = #craftedItem.modMagnitudeMods > 0 or (craftedItem.catalyst and craftedItem.catalyst > 0) + local legalCrafts = { } + local bestEstimatedCraft + local bestEstimatedWeight for _, craft in ipairs(self.itemsTab.build.data.masterMods or { }) do if available[craft.type] and available[craft.type] > 0 and craft.types and craft.types[evaluationItem.type] and not conflictsWithExistingAffix(craft, existingGroups, existingLines) then + t_insert(legalCrafts, craft) + local estimatedWeight = self.tradeQueryGenerator:EstimateBenchCraftWeight(craft, weightSnapshot) + if estimatedWeight and (not bestEstimatedWeight or estimatedWeight > bestEstimatedWeight) then + bestEstimatedCraft = craft + bestEstimatedWeight = estimatedWeight + end + end + end + -- Generated queries already have marginal mod weights. Use them to choose one + -- concrete legal craft; pasted queries without weights retain the exact fallback. + if bestEstimatedCraft then + legalCrafts = bestEstimatedWeight > 0 and { bestEstimatedCraft } or { } + end + for _, craft in ipairs(legalCrafts) do local firstCraftLineIndex = #craftedItem.explicitModLines + 1 for _, line in ipairs(craft) do local modList, extra @@ -1006,6 +1023,9 @@ function TradeQueryClass:GetBestBenchCraftEvaluation(item, slotName, calcFunc, b craftedItem:BuildModList() end local craftOutput = self:ReduceOutput(calcFunc({ repSlotName = slotName, repItem = craftedItem })) + if yieldFunc then + yieldFunc() + end local craftWeight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, craftOutput, self.statSortSelectionList) if craftWeight > weight then output = craftOutput @@ -1025,30 +1045,32 @@ function TradeQueryClass:GetBestBenchCraftEvaluation(item, slotName, calcFunc, b t_remove(craftedItem.explicitModLines, #craftedItem.explicitModLines) end end - end end return output, weight, bestCraft, bestCraftItemString, bestCraftLineIndexes, bestReplacedCraft end -- Method to evaluate a result by getting it's output and weight -function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, baseOutput) +function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, baseOutput, yieldFunc) local result = self.resultTbl[row_idx][result_index] - if not calcFunc then -- Always evaluate when calcFunc is given + if not calcFunc then calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() - local onlyWeightedBaseOutput = self:ReduceOutput(baseOutput) - if not self.onlyWeightedBaseOutput[row_idx] then - self.onlyWeightedBaseOutput[row_idx] = { } - end - if not self.lastComparedWeightList[row_idx] then - self.lastComparedWeightList[row_idx] = { } - end - -- If the interesting stats are the same (the build hasn't changed) and result has already been evaluated, then just return that - if result.evaluation and tableDeepEquals(onlyWeightedBaseOutput, self.onlyWeightedBaseOutput[row_idx][result_index]) and tableDeepEquals(self.statSortSelectionList, self.lastComparedWeightList[row_idx][result_index]) then - return result.evaluation - end - self.onlyWeightedBaseOutput[row_idx][result_index] = onlyWeightedBaseOutput - self.lastComparedWeightList[row_idx][result_index] = self.statSortSelectionList end + local onlyWeightedBaseOutput = self:ReduceOutput(baseOutput) + if not self.onlyWeightedBaseOutput[row_idx] then + self.onlyWeightedBaseOutput[row_idx] = { } + end + if not self.lastComparedWeightList[row_idx] then + self.lastComparedWeightList[row_idx] = { } + end + -- A shared calculator is an optimisation, not a cache bypass. Reuse the result + -- whenever the build outputs and selected weights still match. + if result.evaluation + and tableDeepEquals(onlyWeightedBaseOutput, self.onlyWeightedBaseOutput[row_idx][result_index]) + and tableDeepEquals(self.statSortSelectionList, self.lastComparedWeightList[row_idx][result_index]) then + return result.evaluation + end + self.onlyWeightedBaseOutput[row_idx][result_index] = onlyWeightedBaseOutput + self.lastComparedWeightList[row_idx][result_index] = self.statSortSelectionList local slotTbl = self.slotTables[row_idx] local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId local slotName = jewelNodeId and "Jewel " .. tostring(jewelNodeId) or slotTbl.slotName @@ -1057,10 +1079,17 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba 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]) end - local output12 = self:ReduceOutput(calcFunc({ addNodes = { [addedNodes[1]] = true, [addedNodes[2]] = true } })) - local output13 = self:ReduceOutput(calcFunc({ addNodes = { [addedNodes[1]] = true, [addedNodes[3]] = true } })) - local output23 = self:ReduceOutput(calcFunc({ addNodes = { [addedNodes[2]] = true, [addedNodes[3]] = true } })) - local output123 = self:ReduceOutput(calcFunc({ addNodes = { [addedNodes[1]] = true, [addedNodes[2]] = true, [addedNodes[3]] = true } })) + local function calculateNodes(nodes) + local output = calcFunc({ addNodes = nodes }) + if yieldFunc then + yieldFunc() + end + return self:ReduceOutput(output) + end + local output12 = calculateNodes({ [addedNodes[1]] = true, [addedNodes[2]] = true }) + local output13 = calculateNodes({ [addedNodes[1]] = true, [addedNodes[3]] = true }) + local output23 = calculateNodes({ [addedNodes[2]] = true, [addedNodes[3]] = true }) + local output123 = calculateNodes({ [addedNodes[1]] = true, [addedNodes[2]] = true, [addedNodes[3]] = true }) -- Sometimes the third node is as powerful as a wet noodle, so use weight per point spent, including the jewel socket local weight12 = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, output12, self.statSortSelectionList) / 4 local weight13 = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, output13, self.statSortSelectionList) / 4 @@ -1077,10 +1106,17 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba local item = new("Item"):Item(result.item_string) local output = self:ReduceOutput(calcFunc({ repSlotName = slotName, repItem = item })) + if yieldFunc then + yieldFunc() + end local weight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, output, self.statSortSelectionList) local benchCraft, benchCraftItemString, benchCraftLineIndexes, benchCraftReplaced if slotTbl.considerBenchCraft then - output, weight, benchCraft, benchCraftItemString, benchCraftLineIndexes, benchCraftReplaced = self:GetBestBenchCraftEvaluation(item, slotName, calcFunc, baseOutput, output, weight) + local weightSnapshot = result.benchCraftWeightSnapshot + if weightSnapshot and not tableDeepEquals(self.statSortSelectionList, weightSnapshot.statWeights) then + weightSnapshot = nil + end + output, weight, benchCraft, benchCraftItemString, benchCraftLineIndexes, benchCraftReplaced = self:GetBestBenchCraftEvaluation(item, slotName, calcFunc, baseOutput, output, weight, weightSnapshot, yieldFunc) end result.evaluation = {{ output = output, @@ -1311,6 +1347,7 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro item.enchantModLines = {} end itemsSafe[i].item_string = item:BuildRaw() + itemsSafe[i].benchCraftWeightSnapshot = context.benchCraftWeightSnapshot end self.resultTbl[context.row_idx] = itemsSafe diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index f562c89bf47..ac4132dd642 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -204,6 +204,64 @@ function TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, newOutput, st return meanStatDiff end +function TradeQueryGeneratorClass:CreateBenchCraftWeightSnapshot(statWeights) + if not self.modWeights or #self.modWeights == 0 then + return + end + return { + modWeights = copyTable(self.modWeights), + statWeights = copyTable(statWeights or { }), + } +end + +function TradeQueryGeneratorClass:EstimateBenchCraftWeight(craft, weightSnapshot) + local weightSource = weightSnapshot and weightSnapshot.modWeights + if not weightSource then + return nil + end + self.benchCraftWeightCaches = self.benchCraftWeightCaches or { } + if not self.benchCraftWeightCaches[weightSource] then + local weightsByTradeMod = { } + for _, entry in ipairs(weightSource) do + weightsByTradeMod[entry.tradeModId] = entry + end + self.benchCraftWeightCaches[weightSource] = { + weightsByTradeMod = weightsByTradeMod, + craftWeights = { }, + } + end + local cache = self.benchCraftWeightCaches[weightSource] + local cached = cache.craftWeights[craft] + if cached ~= nil then + return cached or nil + end + + local score = 0 + local matchedWeight = false + for index, line in ipairs(craft) do + local statOrder = craft.statOrder and craft.statOrder[index] + local explicitMods = self.modData and self.modData.Explicit + local modEntry = statOrder and explicitMods and explicitMods[tostring(statOrder) .. "_" .. craft.group] + local weightEntry = modEntry and cache.weightsByTradeMod[modEntry.tradeMod.id] + if weightEntry then + local rangedLine = itemLib.applyRange(line, main.defaultItemAffixQuality or 0.5, 1, 1) + local _, value = tradeHelpers.findTradeHash(rangedLine) + if value == nil and not modEntry.tradeMod.text:find("#", 1, true) then + value = 1 + end + if value ~= nil then + if weightEntry.invert then + value = -value + end + score = score + weightEntry.weight * value + matchedWeight = true + end + end + end + cache.craftWeights[craft] = matchedWeight and score or false + return matchedWeight and score or nil +end + function TradeQueryGeneratorClass:ProcessMod(modId, mod, tradeQueryStatsParsed, itemCategoriesMask, itemCategoriesOverride) if type(modId) == "string" and modId:find("HellscapeDownside") ~= nil then -- skip scourge downsides, they often don't follow standard parsing rules, and should basically never be beneficial anyways goto continue @@ -745,6 +803,7 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) -- Test each mod one at a time and cache the normalized Stat (configured earlier) diff to use as weight self.modWeights = { } + self.benchCraftWeightCaches = nil self.alreadyWeightedMods = { } self.calcContext = { @@ -1119,6 +1178,11 @@ function TradeQueryGeneratorClass:FinishQuery() end local queryJson = dkjson.encode(queryTable) + if self.requesterContext then + self.requesterContext.benchCraftWeightSnapshot = self.requesterContext.slotTbl + and self.requesterContext.slotTbl.considerBenchCraft + and self:CreateBenchCraftWeightSnapshot(self.calcContext.options.statWeights) or nil + end self.requesterCallback(self.requesterContext, queryJson, errMsg) -- Close blocker popup @@ -1189,7 +1253,7 @@ function TradeQueryGeneratorClass:RequestQuery(slot, context, statWeights, callb if supportsBenchCraft then controls.considerBenchCraft = new("CheckBoxControl", { "TOPRIGHT", lastItemAnchor, "BOTTOMRIGHT" }, { 0, 5, 18 }, "Bench Craft:", function(state) end, - "Sorts fetched results by their best bench craft or replacement.") + "Sorts fetched results using the highest-weight legal bench craft or replacement.") controls.considerBenchCraft.state = self.lastConsiderBenchCraft == true updateLastAnchor(controls.considerBenchCraft) end From 91af7d94a20eb95498ee18ce27cb305f05d52942 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Wed, 12 Aug 2026 00:38:37 +0200 Subject: [PATCH 7/8] Keep Trader result evaluation responsive Evaluate fetched items cooperatively across frames and keep fetch identity separate from presentation state. This prevents stale searches, re-sorts, and action tooltips from publishing or reading obsolete results. --- spec/System/TestTradeQueryGenerator_spec.lua | 4 +- spec/System/TestTradeQueryRequests_spec.lua | 4 +- spec/System/TestTradeQuery_spec.lua | 159 +++++++++++- src/Classes/TradeQuery.lua | 252 ++++++++++++++++--- src/Classes/TradeQueryGenerator.lua | 4 +- 5 files changed, 370 insertions(+), 53 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 1d557e113a9..579172bb5ed 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -155,7 +155,7 @@ describe("TradeQueryGenerator", function() describe("EstimateBenchCraftWeight", function() it("adds the weighted values of every craft line", function() - local queryGen = new("TradeQueryGenerator", { itemsTab = { } }) + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { } }) queryGen.modData = { Explicit = { ["1203_TestAttributes"] = { tradeMod = { id = "explicit.stat_4080418644", text = "+# to Strength" } }, @@ -178,7 +178,7 @@ describe("TradeQueryGenerator", function() end) it("keeps generated mod and stat weights immutable", function() - local queryGen = new("TradeQueryGenerator", { itemsTab = { } }) + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { } }) queryGen.modWeights = { { tradeModId = "explicit.test", weight = 2 } } local statWeights = { { stat = "Life", weightMult = 1 } } diff --git a/spec/System/TestTradeQueryRequests_spec.lua b/spec/System/TestTradeQueryRequests_spec.lua index aab65c03293..598165712c7 100644 --- a/spec/System/TestTradeQueryRequests_spec.lua +++ b/spec/System/TestTradeQueryRequests_spec.lua @@ -276,14 +276,14 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] local request = table.remove(requests.requestQueue.fetch, 1) request.callback(response) - local item = new("Item", fetchedItems[1].item_string) + local item = new("Item"):Item(fetchedItems[1].item_string) assert.is_true(item.explicitModLines[1].prefix) assert.is_true(item.explicitModLines[2].prefix) assert.are.equal(item.explicitModLines[1].modGroup, item.explicitModLines[2].modGroup) assert.is_true(item.explicitModLines[3].suffix) assert.is_true(item.explicitModLines[4].suffix) assert.are_not.equal(item.explicitModLines[3].modGroup, item.explicitModLines[4].modGroup) - local availability = new("TradeQuery", { itemsTab = { } }):GetBenchCraftAvailability(item) + local availability = new("TradeQuery"):TradeQuery({ itemsTab = { } }):GetBenchCraftAvailability(item) assert.are.equal(2, availability.Prefix) assert.are.equal(1, availability.Suffix) end) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 2c224fac0ef..934187c19ec 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -6,6 +6,120 @@ describe("TradeQuery", function() mock_tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) end) + describe("cooperative result evaluation", function() + it("resumes fetched result work over multiple frames", function() + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tradeQuery.controls.priceButton1 = { label = "Price Item" } + tradeQuery.controls.pbNotice = { label = "" } + tradeQuery.resultTbl[1] = { { }, { } } + local events = { } + tradeQuery.UpdateControlsWithItems = function(_, _, yieldFunc) + table.insert(events, "first") + yieldFunc(1, 2) + table.insert(events, "second") + yieldFunc(2, 2) + table.insert(events, "done") + end + + tradeQuery:StartResultEvaluation(1) + + assert.are.same({ }, events) + assert.are.equal("Eval 0/2...", tradeQuery.controls.priceButton1.label) + + tradeQuery:ProcessResultEvaluations() + assert.are.same({ "first" }, events) + assert.are.equal("Eval 1/2...", tradeQuery.controls.priceButton1.label) + + tradeQuery:ProcessResultEvaluations() + assert.are.same({ "first", "second" }, events) + assert.are.equal("Eval 2/2...", tradeQuery.controls.priceButton1.label) + + tradeQuery:ProcessResultEvaluations() + assert.are.same({ "first", "second", "done" }, events) + assert.are.equal("Price Item", tradeQuery.controls.priceButton1.label) + assert.is_nil(tradeQuery.resultEvaluationContexts[1]) + end) + + it("clears the prior selection before scheduling a new evaluation", function() + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + local dropdownList + tradeQuery.controls.priceButton1 = { label = "Price Item" } + tradeQuery.controls.resultDropdown1 = { + SetList = function(_, list) + dropdownList = list + end, + } + tradeQuery.controls.fullPrice = { label = "" } + tradeQuery.resultTbl[1] = { { } } + tradeQuery.sortedResultTbl[1] = { { index = 1 } } + tradeQuery.itemIndexTbl[1] = 1 + tradeQuery.totalPrice[1] = { amount = 1, currency = "chaos" } + tradeQuery.UpdateControlsWithItems = function() end + + tradeQuery:StartResultEvaluation(1) + + assert.is_nil(tradeQuery.sortedResultTbl[1]) + assert.is_nil(tradeQuery.itemIndexTbl[1]) + assert.is_nil(tradeQuery.totalPrice[1]) + assert.are.same({ }, dropdownList) + assert.are.equal("^7Total Price: ", tradeQuery.controls.fullPrice.label) + end) + + it("does not replace an active fetch with evaluation of old results", function() + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tradeQuery.controls.priceButton1 = { label = "Price Item" } + tradeQuery.resultTbl[1] = { { } } + local evaluated = false + tradeQuery.UpdateControlsWithItems = function() + evaluated = true + end + + local fetchContext = tradeQuery:StartResultFetch(1) + tradeQuery:StartResultEvaluation(1) + + assert.is_true(tradeQuery:IsResultFetchCurrent(1, fetchContext)) + assert.is_nil(tradeQuery.resultEvaluationContexts[1]) + assert.is_false(evaluated) + assert.are.equal("Searching...", tradeQuery.controls.priceButton1.label) + end) + + it("rejects a response from a superseded fetch", function() + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tradeQuery.controls.priceButton1 = { label = "Price Item" } + + local firstFetch = tradeQuery:StartResultFetch(1) + local secondFetch = tradeQuery:StartResultFetch(1) + + assert.is_false(tradeQuery:FinishResultFetch(1, firstFetch)) + assert.are.equal("Searching...", tradeQuery.controls.priceButton1.label) + assert.is_true(tradeQuery:FinishResultFetch(1, secondFetch)) + assert.are.equal("Price Item", tradeQuery.controls.priceButton1.label) + end) + + it("publishes only the replacement of a suspended evaluation", function() + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tradeQuery.controls.priceButton1 = { label = "Price Item" } + tradeQuery.controls.pbNotice = { label = "" } + tradeQuery.resultTbl[1] = { { } } + local run = 0 + local published + tradeQuery.UpdateControlsWithItems = function(_, _, yieldFunc) + run = run + 1 + local currentRun = run + yieldFunc(1, 1) + published = currentRun + end + + tradeQuery:StartResultEvaluation(1) + tradeQuery:ProcessResultEvaluations() + tradeQuery:StartResultEvaluation(1) + tradeQuery:ProcessResultEvaluations() + tradeQuery:ProcessResultEvaluations() + + assert.are.equal(2, published) + assert.is_nil(tradeQuery.resultEvaluationContexts[1]) + end) + end) describe("result dropdown tooltipFunc", function() -- Builds a TradeQuery with the strict minimum needed for -- PriceItemRowDisplay to construct row 1 without exploding. Only the @@ -76,7 +190,7 @@ describe("TradeQuery", function() }) tq.itemsTab.AddItemTooltip = function() end local dropdown = buildRow1Dropdown(tq) - local tooltip = new("Tooltip") + local tooltip = new("Tooltip"):Tooltip() dropdown.tooltipFunc(tooltip, "DROP", 1, nil) @@ -104,7 +218,7 @@ describe("TradeQuery", function() }) tq.itemsTab.AddItemTooltip = function() end local dropdown = buildRow1Dropdown(tq) - local tooltip = new("Tooltip") + local tooltip = new("Tooltip"):Tooltip() dropdown.tooltipFunc(tooltip, "DROP", 1, nil) @@ -138,7 +252,7 @@ describe("TradeQuery", function() local previewActive = true tq.IsBenchCraftPreviewActive = function() return previewActive end local dropdown = buildRow1Dropdown(tq) - local tooltip = new("Tooltip") + local tooltip = new("Tooltip"):Tooltip() dropdown.tooltipFunc(tooltip, "DROP", 1, nil) @@ -160,6 +274,31 @@ describe("TradeQuery", function() assert.is_nil(tooltip.childTooltips) end) end) + describe("result action controls", function() + it("ignore a stale selection while asynchronous evaluation is pending", function() + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tradeQuery.itemsTab.activeItemSet = {} + tradeQuery.itemsTab.slots = {} + tradeQuery.slotTables[1] = { slotName = "Ring 1" } + tradeQuery.resultTbl[1] = { { + item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring", + amount = 1, + currency = "chaos", + } } + tradeQuery.sortedResultTbl[1] = { { index = 1 } } + tradeQuery:PriceItemRowDisplay(1, nil, 0, 20) + tradeQuery.itemIndexTbl[1] = 2 + local tooltip = new("Tooltip"):Tooltip() + + assert.has_no.errors(function() + tradeQuery.controls.importButton1.tooltipFunc(tooltip) + end) + assert.is_false(tradeQuery.controls.importButton1.enabled()) + assert.has_no.errors(function() + tradeQuery.controls.whisperButton1.tooltipFunc(tooltip) + end) + end) + end) describe("ReduceOutput", function() it("preserves lower-is-better values for weighted result comparison", function() local weights = { @@ -255,7 +394,7 @@ describe("TradeQuery", function() end local function evaluate(itemString, crafts, calcOverride, yieldFunc, weightSnapshot) - local tradeQuery = new("TradeQuery", { itemsTab = { } }) + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = { } }) tradeQuery.tradeQueryGenerator = mock_queryGen tradeQuery.itemsTab.build = { data = { masterMods = crafts or { prefixCraft, suffixCraft } } } tradeQuery.statSortSelectionList = { { stat = "Life", weightMult = 1 } } @@ -295,7 +434,7 @@ describe("TradeQuery", function() end) it("reuses cached craft evaluations when a shared calculator is provided", function() - local tradeQuery = new("TradeQuery", { itemsTab = { } }) + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = { } }) tradeQuery.tradeQueryGenerator = mock_queryGen tradeQuery.statSortSelectionList = { { stat = "Life", weightMult = 1 } } tradeQuery.slotTables[1] = { slotName = "Ring 1", considerBenchCraft = true } @@ -344,7 +483,7 @@ describe("TradeQuery", function() end return { Life = 100 } end) - local previewItem = new("Item", evaluation.benchCraftItemString) + local previewItem = new("Item"):Item(evaluation.benchCraftItemString) local previewModLine = previewItem.explicitModLines[evaluation.benchCraftLineIndexes[1]] local previewCraftLine = itemLib.applyRange(previewModLine.line, previewModLine.range, 1, 1) @@ -383,7 +522,7 @@ describe("TradeQuery", function() local itemString = makeRareRing(3, 2, { "{crafted}{suffix}+20 to Dexterity" }) :gsub("Implicits: 0", "Catalyst: Intrinsic\nCatalystQuality: 20\nImplicits: 0") local evaluation = evaluate(itemString, { suffixCraft }) - local previewItem = new("Item", evaluation.benchCraftItemString) + local previewItem = new("Item"):Item(evaluation.benchCraftItemString) local previewModLine = previewItem.explicitModLines[evaluation.benchCraftLineIndexes[1]] assert.is_true(previewModLine.crafted) @@ -507,7 +646,7 @@ describe("TradeQuery", function() assert.are.equal(calls, yields) end) - it("fully evaluates only the highest-weight legal bench craft", function() + it("fully evaluates only the highest estimated-weight legal bench craft", function() local predictedBest = { type = "Suffix", group = "PredictedBest", @@ -623,7 +762,7 @@ describe("TradeQuery", function() end return { Life = 100 } end) - local tooltipQuery = new("TradeQuery", { itemsTab = { } }) + local tooltipQuery = new("TradeQuery"):TradeQuery({ itemsTab = { } }) tooltipQuery.itemsTab.activeItemSet = { } tooltipQuery.itemsTab.slots = { } tooltipQuery.slotTables[1] = { slotName = "Ring 1" } @@ -644,7 +783,7 @@ describe("TradeQuery", function() end tooltipQuery.IsBenchCraftPreviewActive = function() return true end tooltipQuery:PriceItemRowDisplay(1, nil, 0, 20) - local tooltip = new("Tooltip") + local tooltip = new("Tooltip"):Tooltip() tooltipQuery.controls.resultDropdown1.tooltipFunc(tooltip, "DROP", 1, nil) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 0106d46ab23..76be428105c 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -58,6 +58,14 @@ function TradeQueryClass:TradeQuery(itemsTab) self.backoffFinish = nil -- last query for each row self.lastQueries = {} + -- Result evaluation is resumed one calculation at a time so expensive + -- build comparisons never monopolise the UI thread after a fetch. + self.resultEvaluationContexts = {} + self.resultEvaluationQueue = {} + self.resultEvaluationQueued = {} + -- Identity tokens keep network fetches separate from result evaluation and + -- prevent an older response from replacing a newer search. + self.resultFetchContexts = {} self.tradeQueryRequests = new("TradeQueryRequests"):TradeQueryRequests() if not main.api then @@ -443,7 +451,9 @@ on trade site to work on other leagues and realms)]] self.controls.itemSortSelection = new("DropDownControl"):DropDownControl({"TOPRIGHT", self.controls.StatWeightMultipliersButton, "TOPLEFT"}, {-8, 0, 170, row_height}, self.itemSortSelectionList, function(index, value) self.pbItemSortSelectionIndex = index for row_idx, _ in pairs(self.resultTbl) do - self:UpdateControlsWithItems(row_idx) + if not self.resultFetchContexts[row_idx] then + self:StartResultEvaluation(row_idx) + end end end) self.controls.itemSortSelection.tooltipText = @@ -654,6 +664,7 @@ Highest Weight - Displays the order retrieved from trade]] end main.onFrameFuncs["TradeQueryRequests"] = function() self.tradeQueryRequests:ProcessQueue(onRateLimit) + self:ProcessResultEvaluations() if self.countDown then coroutine.resume(self.countDown) if coroutine.status(self.countDown) == "dead" then @@ -747,7 +758,9 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) self.statSortSelectionList = statSortSelectionList end for row_idx in pairs(self.resultTbl) do - self:UpdateControlsWithItems(row_idx) + if not self.resultFetchContexts[row_idx] then + self:StartResultEvaluation(row_idx) + end end end) controls.cancel = new("ButtonControl"):ButtonControl({ "BOTTOM", nil, "BOTTOM" }, { 0, -10, 80, 20 }, "Cancel", function() @@ -781,6 +794,131 @@ function TradeQueryClass:SetNotice(notice_control, msg) notice_control.label = msg end +function TradeQueryClass:SetResultEvaluationProgress(rowIdx, current, total) + local button = self.controls["priceButton" .. rowIdx] + if button then + button.label = s_format("Eval %d/%d...", current or 0, total or 0) + end +end + +function TradeQueryClass:CancelResultEvaluation(rowIdx) + self.resultEvaluationContexts[rowIdx] = nil + local button = self.controls["priceButton" .. rowIdx] + if button then + button.label = "Price Item" + end +end + +function TradeQueryClass:StartResultFetch(rowIdx) + self:CancelResultEvaluation(rowIdx) + local context = { } + self.resultFetchContexts[rowIdx] = context + local button = self.controls["priceButton" .. rowIdx] + if button then + button.label = "Searching..." + end + return context +end + +function TradeQueryClass:IsResultFetchCurrent(rowIdx, context) + return self.resultFetchContexts[rowIdx] == context +end + +function TradeQueryClass:FinishResultFetch(rowIdx, context) + if not self:IsResultFetchCurrent(rowIdx, context) then + return false + end + self.resultFetchContexts[rowIdx] = nil + local button = self.controls["priceButton" .. rowIdx] + if button then + button.label = "Price Item" + end + return true +end + +function TradeQueryClass:CancelResultFetch(rowIdx) + self.resultFetchContexts[rowIdx] = nil + self:CancelResultEvaluation(rowIdx) +end + +function TradeQueryClass:StartResultEvaluation(rowIdx) + if self.resultFetchContexts[rowIdx] then + return + end + local results = self.resultTbl[rowIdx] or { } + self.itemIndexTbl[rowIdx] = nil + self.sortedResultTbl[rowIdx] = nil + self.totalPrice[rowIdx] = nil + local dropdown = self.controls["resultDropdown" .. rowIdx] + if dropdown then + dropdown.selIndex = 1 + dropdown:SetList({ }) + end + if self.controls.fullPrice then + self.controls.fullPrice.label = "^7Total Price: " .. self:GetTotalPriceString() + end + local context = { + total = #results, + } + context.co = coroutine.create(function() + self:UpdateControlsWithItems(rowIdx, function(current, total) + if self.resultEvaluationContexts[rowIdx] ~= context then + return + end + self:SetResultEvaluationProgress(rowIdx, current, total) + coroutine.yield() + end) + end) + self.resultEvaluationContexts[rowIdx] = context + self:SetResultEvaluationProgress(rowIdx, 0, context.total) + if not self.resultEvaluationQueued[rowIdx] then + t_insert(self.resultEvaluationQueue, rowIdx) + self.resultEvaluationQueued[rowIdx] = true + end +end + +function TradeQueryClass:ProcessResultEvaluations() + local rowIdx = t_remove(self.resultEvaluationQueue, 1) + if not rowIdx then + return + end + self.resultEvaluationQueued[rowIdx] = nil + local context = self.resultEvaluationContexts[rowIdx] + if not context then + return + end + + local ok, errMsg = coroutine.resume(context.co) + if not ok then + if self.resultEvaluationContexts[rowIdx] == context then + self.resultEvaluationContexts[rowIdx] = nil + local button = self.controls["priceButton" .. rowIdx] + if button then + button.label = "Price Item" + end + if self.controls.pbNotice then + self:SetNotice(self.controls.pbNotice, "Error while evaluating trade results: " .. tostring(errMsg)) + end + end + ConPrintf("Trade result evaluation error: %s", errMsg) + return + end + + if self.resultEvaluationContexts[rowIdx] ~= context then + return + end + if coroutine.status(context.co) == "dead" then + self.resultEvaluationContexts[rowIdx] = nil + local button = self.controls["priceButton" .. rowIdx] + if button then + button.label = "Price Item" + end + else + t_insert(self.resultEvaluationQueue, rowIdx) + self.resultEvaluationQueued[rowIdx] = true + end +end + function TradeQueryClass:IsBenchCraftPreviewActive() return IsKeyDown("CTRL") end @@ -929,7 +1067,7 @@ local function conflictsWithExistingAffix(craft, existingGroups, existingLines) end local function getItemWithoutCraftedMods(item) - local strippedItem = new("Item", item:BuildRaw()) + local strippedItem = new("Item"):Item(item:BuildRaw()) local retainedModLines = { } local replacedCraftLines = { } local replacedCraftType @@ -951,7 +1089,7 @@ local function getItemWithoutCraftedMods(item) if replacedCraftType then replacedCraft = replacedCraft .. " ^8(" .. replacedCraftType .. ")" end - return new("Item", strippedItem:BuildRaw()), replacedCraft + return new("Item"):Item(strippedItem:BuildRaw()), replacedCraft end function TradeQueryClass:GetBestBenchCraftEvaluation(item, slotName, calcFunc, baseOutput, output, weight, weightSnapshot, yieldFunc) @@ -975,7 +1113,7 @@ function TradeQueryClass:GetBestBenchCraftEvaluation(item, slotName, calcFunc, b local bestCraftLineIndexes local bestReplacedCraft local originalItem = evaluationItem:BuildRaw() - local craftedItem = new("Item", originalItem) + local craftedItem = new("Item"):Item(originalItem) local requiresFullParse = #craftedItem.modMagnitudeMods > 0 or (craftedItem.catalyst and craftedItem.catalyst > 0) local legalCrafts = { } local bestEstimatedCraft @@ -1039,7 +1177,7 @@ function TradeQueryClass:GetBestBenchCraftEvaluation(item, slotName, calcFunc, b end end if requiresFullParse then - craftedItem = new("Item", originalItem) + craftedItem = new("Item"):Item(originalItem) else for _ = 1, #craft do t_remove(craftedItem.explicitModLines, #craftedItem.explicitModLines) @@ -1062,7 +1200,7 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba if not self.lastComparedWeightList[row_idx] then self.lastComparedWeightList[row_idx] = { } end - -- A shared calculator is an optimisation, not a cache bypass. Reuse the result + -- A shared calculator is an optimization, not a cache bypass. Reuse the result -- whenever the build outputs and selected weights still match. if result.evaluation and tableDeepEquals(onlyWeightedBaseOutput, self.onlyWeightedBaseOutput[row_idx][result_index]) @@ -1148,6 +1286,7 @@ function TradeQueryClass:UpdateDropdownList(row_idx) self.controls["resultDropdown".. row_idx]:SetList(dropdownLabels) end function TradeQueryClass:ResetResultRow(rowIdx) + self:CancelResultFetch(rowIdx) self.itemIndexTbl[rowIdx] = nil self.sortedResultTbl[rowIdx] = nil self.resultTbl[rowIdx] = nil @@ -1155,12 +1294,12 @@ function TradeQueryClass:ResetResultRow(rowIdx) self:UpdateDropdownList(rowIdx) self.controls.fullPrice.label = "^7Total Price: " .. self:GetTotalPriceString() end -function TradeQueryClass:UpdateControlsWithItems(row_idx) +function TradeQueryClass:UpdateControlsWithItems(row_idx, yieldFunc) local sortMode = self.itemSortSelectionList[self.pbItemSortSelectionIndex] - local sortedItems, errMsg = self:SortFetchResults(row_idx, sortMode) + local sortedItems, errMsg = self:SortFetchResults(row_idx, sortMode, yieldFunc) if errMsg == "MissingConversionRates" then self:SetNotice(self.controls.pbNotice, "^4Currency rates unavailable. Falling back to Stat Value sort.") - sortedItems, errMsg = self:SortFetchResults(row_idx, self.sortModes.StatValue) + sortedItems, errMsg = self:SortFetchResults(row_idx, self.sortModes.StatValue, yieldFunc) elseif errMsg then self:SetNotice(self.controls.pbNotice, "Error: " .. errMsg) return @@ -1197,14 +1336,28 @@ function TradeQueryClass:SetFetchResultReturn(row_idx, index) end -- Method to sort the fetched results -function TradeQueryClass:SortFetchResults(row_idx, mode) +function TradeQueryClass:SortFetchResults(row_idx, mode, yieldFunc) local calcFunc, baseOutput - local function getResultWeight(result_index) + local evaluationCache = { } + local function getResultEvaluation(result_index) + if evaluationCache[result_index] then + return evaluationCache[result_index] + end if not calcFunc then calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() end + local function yieldAfterCalculation() + if yieldFunc then + yieldFunc(result_index, #self.resultTbl[row_idx]) + end + end + evaluationCache[result_index] = self:GetResultEvaluation( + row_idx, result_index, calcFunc, baseOutput, yieldAfterCalculation) + return evaluationCache[result_index] + end + local function getResultWeight(result_index) local sum = 0 - for _, eval in ipairs(self:GetResultEvaluation(row_idx, result_index)) do + for _, eval in ipairs(getResultEvaluation(result_index)) do sum = sum + eval.weight end return sum @@ -1303,6 +1456,7 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro 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() + self:CancelResultFetch(row_idx) self.tradeQueryGenerator:RequestQuery(activeSlot, { slotTbl = slotTbl, controls = controls, row_idx = row_idx }, self.statSortSelectionList, function(context, query, errMsg) if errMsg then self:SetNotice(context.controls.pbNotice, colorCodes.NEGATIVE .. errMsg) @@ -1316,13 +1470,15 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro controls["uri"..context.row_idx]:SetText(url, true) return end - context.controls["priceButton"..context.row_idx].label = "Searching..." + local fetchContext = self:StartResultFetch(context.row_idx) self.lastQueries[row_idx] = query self.tradeQueryRequests:SearchWithQueryWeightAdjusted(self.pbRealm, self.pbLeague, query, function(items, errMsg) + if not self:FinishResultFetch(context.row_idx, fetchContext) then + return + end if errMsg then self:SetNotice(context.controls.pbNotice, colorCodes.NEGATIVE .. errMsg) - context.controls["priceButton"..context.row_idx].label = "Price Item" return else self:SetNotice(context.controls.pbNotice, "") @@ -1351,11 +1507,13 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro end self.resultTbl[context.row_idx] = itemsSafe - self:UpdateControlsWithItems(context.row_idx) - context.controls["priceButton"..context.row_idx].label = "Price Item" + self:StartResultEvaluation(context.row_idx) end, { callbackQueryId = function(queryId) + if not self:IsResultFetchCurrent(context.row_idx, fetchContext) then + return + end local url = self.tradeQueryRequests:buildUrl(self.hostName .. "trade/search", self.pbRealm, self.pbLeague, queryId) controls["uri"..context.row_idx]:SetText(url, true) end @@ -1411,12 +1569,15 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end controls["priceButton"..row_idx] = new("ButtonControl"):ButtonControl({ "TOPLEFT", controls["uri"..row_idx], "TOPRIGHT"}, {8, 0, 100, row_height}, "Price Item", function() - controls["priceButton"..row_idx].label = "Searching..." + local fetchContext = self:StartResultFetch(row_idx) local url = controls["uri" .. row_idx].buf if not url:find("^https://") then url = "https://" .. url end self.tradeQueryRequests:SearchWithURL(url, function(items, errMsg, query) + if not self:FinishResultFetch(row_idx, fetchContext) then + return + end if errMsg then self:SetNotice(controls.pbNotice, "Error: " .. errMsg) else @@ -1425,18 +1586,18 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite local selectedSlot = getSelectedSlot() local itemsSafe = self:FilterToSafeItems(items, selectedSlot and selectedSlot.slotName) self.resultTbl[row_idx] = itemsSafe - self:UpdateControlsWithItems(row_idx) + self:StartResultEvaluation(row_idx) end - controls["priceButton"..row_idx].label = "Price Item" end) end) controls["priceButton"..row_idx].enabled = function() local isAuthorized = main.api.authToken ~= nil local validURL = controls["uri"..row_idx].validURL - local isSearching = controls["priceButton"..row_idx].label == "Searching..." + local isSearching = self.resultFetchContexts[row_idx] ~= nil + local isEvaluating = self.resultEvaluationContexts[row_idx] ~= nil local selectedJewelSlot = slotTbl.selectedJewelNodeId and self.itemsTab.sockets[slotTbl.selectedJewelNodeId] local hasRequiredJewelSlot = not slotTbl.unique or selectedJewelSlot and not selectedJewelSlot.inactive - return isAuthorized and validURL and not isSearching and hasRequiredJewelSlot + return isAuthorized and validURL and not isSearching and not isEvaluating and hasRequiredJewelSlot end controls["priceButton"..row_idx].tooltipFunc = function(tooltip) tooltip:Clear() @@ -1491,8 +1652,8 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite if not evaluation or not evaluation.benchCraftItemString or not self:IsBenchCraftPreviewActive() then return end - local previewItem = new("Item", evaluation.benchCraftItemString) - local previewTooltip = tooltip.benchCraftPreviewTooltip or new("Tooltip") + local previewItem = new("Item"):Item(evaluation.benchCraftItemString) + local previewTooltip = tooltip.benchCraftPreviewTooltip or new("Tooltip"):Tooltip() tooltip.benchCraftPreviewTooltip = previewTooltip previewTooltip:Clear() self.itemsTab:AddItemTooltip(previewTooltip, previewItem, tooltipSlot) @@ -1533,8 +1694,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 resultIndex = self.itemIndexTbl[row_idx] + local resultRow = self.resultTbl[row_idx] + return resultIndex and resultRow and resultRow[resultIndex] + 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 selectedResult = getSelectedResult() + if not selectedResult or not selectedResult.item_string then + return + end + self.itemsTab:CreateDisplayItemFromRaw(selectedResult.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) @@ -1550,21 +1720,23 @@ 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 - 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 - self.itemsTab:AddItemTooltip(tooltip, item, tooltipSlot, true) - addMegalomaniacCompareToTooltipIfApplicable(tooltip, selected_result_index) + local selectedResult = getSelectedResult() + if not selectedResult or not selectedResult.item_string then + return end + local item = new("Item"):Item(selectedResult.item_string) + local tooltipSlot = slotTbl.selectedJewelNodeId and self.itemsTab.sockets[slotTbl.selectedJewelNodeId] or activeSlot + self.itemsTab:AddItemTooltip(tooltip, item, tooltipSlot, true) + addMegalomaniacCompareToTooltipIfApplicable(tooltip, self.itemIndexTbl[row_idx]) 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 selectedResult = getSelectedResult() + return selectedResult and selectedResult.item_string ~= nil or false 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 @@ -1580,7 +1752,10 @@ 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]] + local itemResult = getSelectedResult() + if not itemResult then + return + end if itemResult.whisper and (itemResult.priceType ~= "~b/o") then Copy(itemResult.whisper) else @@ -1608,7 +1783,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 ac4132dd642..c3f2a31a3ea 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1251,9 +1251,9 @@ function TradeQueryGeneratorClass:RequestQuery(slot, context, statWeights, callb local supportsBenchCraft = slot and not context.slotTbl.unique and not isJewelSlot and not isAbyssalJewelSlot and not slot.slotName:find("Flask") if supportsBenchCraft then - controls.considerBenchCraft = new("CheckBoxControl", { "TOPRIGHT", lastItemAnchor, "BOTTOMRIGHT" }, + controls.considerBenchCraft = new("CheckBoxControl"):CheckBoxControl({ "TOPRIGHT", lastItemAnchor, "BOTTOMRIGHT" }, { 0, 5, 18 }, "Bench Craft:", function(state) end, - "Sorts fetched results using the highest-weight legal bench craft or replacement.") + "Sorts fetched results using the highest estimated-weight legal bench craft or replacement.") controls.considerBenchCraft.state = self.lastConsiderBenchCraft == true updateLastAnchor(controls.considerBenchCraft) end From 1e8c273b844dc04ade9f84d97ce855de30bcffff Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 00:54:28 +0200 Subject: [PATCH 8/8] Streamline Trader bench craft evaluation Own query-derived weights once per result row, reuse Item magnitude scaling, and evaluate a bounded set of legal candidates. Keep the generated empty-affix filter in the same priority ordering as regular weighted filters. --- spec/System/TestItemParse_spec.lua | 23 +++ spec/System/TestTradeQueryGenerator_spec.lua | 154 +++++++++++++++- spec/System/TestTradeQuery_spec.lua | 152 ++++++++++++++-- src/Classes/Item.lua | 104 ++++++----- src/Classes/TradeQuery.lua | 178 ++++++++++--------- src/Classes/TradeQueryGenerator.lua | 153 ++++++++++++---- 6 files changed, 582 insertions(+), 182 deletions(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 53a14f3d98b..401a4f89f86 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -1306,6 +1306,29 @@ describe("TestAdvancedItemParse #item", function() assert.are.equals(195, chaosDamageInc()) end) + it("calculates catalyst and magnitude scaling for a proposed explicit mod", function() + local item = new("Item"):Item([[ + Rarity: RARE + Test Subject + Sapphire Ring + Catalyst: Intrinsic + CatalystQuality: 20 + Implicits: 0 + {range:0.5}50% increased effect of prefixes + ]]) + item.modMagnitudeMods = { + { tags = { "prefix" }, multiplier = 2 }, + { tags = { "prefix" }, quality = 50 }, + } + local attributePrefix = { modTags = { "attribute" }, prefix = true } + local attributeSuffix = { modTags = { "attribute" }, suffix = true } + + assert.are.equals(2.9, item:GetModLineValueScalar(attributePrefix, "explicit")) + assert.are.equals(1.2, item:GetModLineValueScalar(attributeSuffix, "explicit")) + attributePrefix.unscalable = true + assert.are.equals(1, item:GetModLineValueScalar(attributePrefix, "explicit")) + end) + -- actually a ring so we don't have to allocate a socket local realJewel = [[ Rarity: Rare diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 579172bb5ed..758352b6759 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -172,9 +172,24 @@ describe("TradeQueryGenerator", function() statOrder = { 1203, 1204 }, group = "TestAttributes", } - local snapshot = queryGen:CreateBenchCraftWeightSnapshot({ { stat = "Life", weightMult = 1 } }) + local evaluationPlan = queryGen:CreateBenchCraftEvaluationPlan({ { stat = "Life", weightMult = 1 } }) - assert.are.equal(80, queryGen:EstimateBenchCraftWeight(craft, snapshot)) + assert.are.equal(80, queryGen:EstimateBenchCraftWeight(craft, evaluationPlan)) + assert.are.equal(96, queryGen:EstimateBenchCraftWeight(craft, evaluationPlan, 1.2)) + assert.are.equal(120, queryGen:EstimateBenchCraftWeight(craft, evaluationPlan, 1.5)) + assert.are.equal(80, queryGen:EstimateBenchCraftWeight(craft, evaluationPlan)) + assert.are.equal(80, evaluationPlan.craftWeights[craft]) + end) + + it("keeps the first craft when the highest levels in a group are tied", function() + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { } }) + local low = { group = "Test", level = 1 } + local firstHigh = { group = "Test", level = 2 } + local secondHigh = { group = "Test", level = 2 } + + local crafts = queryGen:GetHighestLevelBenchCrafts({ low, firstHigh, secondHigh }) + + assert.are.same({ firstHigh }, crafts) end) it("keeps generated mod and stat weights immutable", function() @@ -182,12 +197,89 @@ describe("TradeQueryGenerator", function() queryGen.modWeights = { { tradeModId = "explicit.test", weight = 2 } } local statWeights = { { stat = "Life", weightMult = 1 } } - local snapshot = queryGen:CreateBenchCraftWeightSnapshot(statWeights) + local evaluationPlan = queryGen:CreateBenchCraftEvaluationPlan(statWeights) queryGen.modWeights[1].weight = 20 statWeights[1].weightMult = 10 - assert.are.equal(2, snapshot.modWeights[1].weight) - assert.are.equal(1, snapshot.statWeights[1].weightMult) + assert.are.equal(2, evaluationPlan.weightsByTradeMod["explicit.test"].weight) + assert.are.equal(1, evaluationPlan.statWeights[1].weightMult) + end) + + it("finds the best positive compatible bench craft query weight", function() + local prefixLow = { + "+(10-10) to Strength", + type = "Prefix", + level = 1, + types = { Ring = true }, + statOrder = { 1203 }, + group = "TestStrength", + } + local prefixHigh = { + "+(20-20) to Strength", + type = "Prefix", + level = 2, + types = { Ring = true }, + statOrder = { 1203 }, + group = "TestStrength", + } + local suffix = { + "+(10-10) to Dexterity", + type = "Suffix", + types = { Ring = true }, + statOrder = { 1204 }, + group = "TestDexterity", + } + local incompatible = { + "+(100-100) to Strength", + type = "Prefix", + types = { Amulet = true }, + statOrder = { 1203 }, + group = "TestStrength", + } + local negative = { + "+(10-10) to Intelligence", + type = "Prefix", + types = { Belt = true }, + statOrder = { 1205 }, + group = "TestIntelligence", + } + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ + itemsTab = { build = { data = { masterMods = { prefixLow, prefixHigh, suffix, incompatible, negative } } } }, + }) + queryGen.modData = { + Explicit = { + ["1203_TestStrength"] = { tradeMod = { id = "explicit.stat_4080418644", text = "+# to Strength" } }, + ["1204_TestDexterity"] = { tradeMod = { id = "explicit.stat_3261801346", text = "+# to Dexterity" } }, + ["1205_TestIntelligence"] = { tradeMod = { id = "explicit.stat_328541901", text = "+# to Intelligence" } }, + }, + } + queryGen.modWeights = { + { tradeModId = "explicit.stat_4080418644", weight = 2 }, + { tradeModId = "explicit.stat_3261801346", weight = 3 }, + { tradeModId = "explicit.stat_328541901", weight = -4 }, + } + local evaluationPlan = queryGen:CreateBenchCraftEvaluationPlan({ }) + + local weight = queryGen:GetBenchCraftQueryWeight({ type = "Ring" }, evaluationPlan) + + assert.are.equal(40, weight) + assert.is_nil(queryGen:GetBenchCraftQueryWeight({ type = "Belt" }, evaluationPlan)) + end) + + it("weights only items with exactly one empty affix", function() + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { } }) + queryGen.GetBenchCraftQueryWeight = function() + return 60 + end + + local filter, priority = queryGen:GetBenchCraftQueryFilter({ type = "Ring" }, { }) + + assert.are.equal("pseudo.pseudo_number_of_empty_affix_mods", filter.id) + assert.are.equal(1, filter.value.min) + assert.are.equal(1, filter.value.max) + assert.are.equal(60, filter.value.weight) + assert.are.equal(60, priority) + assert.is_nil(filter.priority) end) end) @@ -229,5 +321,57 @@ describe("TradeQueryGenerator", function() assert.is_not_nil(query.filters.socket_filters.filters.sockets) assert.is_not_nil(query.filters.socket_filters.filters.links) end) + + it("ranks the single empty affix weight with regular filters before applying MAX_FILTERS", function() + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { items = { } } }) + queryGen.modWeights = { } + for index = 1, 40 do + table.insert(queryGen.modWeights, { + tradeModId = "explicit.stat_" .. index, + weight = 1, + meanStatDiff = 41 - index, + }) + end + queryGen.calcContext = { + testItem = new("Item"):Item("Rarity: RARE\nNew Item\nGold Ring\nImplicits: 0"), + baseOutput = { }, + baseStatValue = 0, + itemCategoryQueryStr = "accessory.ring", + special = { }, + options = { + statWeights = { }, + influence1 = 1, + influence2 = 1, + includeMirrored = false, + sockets = 6, + links = 6, + }, + } + queryGen.tradeTypeIndex = 1 + queryGen.requesterContext = { slotTbl = { considerBenchCraft = true } } + local receivedPlan + queryGen.GetBenchCraftQueryFilter = function(_, _, evaluationPlan) + receivedPlan = evaluationPlan + return { + id = "pseudo.pseudo_number_of_empty_affix_mods", + value = { min = 1, max = 1, weight = 35.5 }, + }, 35.5 + end + local query + queryGen.requesterCallback = function(_, queryJson) + query = require("dkjson").decode(queryJson).query + end + + queryGen:FinishQuery() + local filtersById = { } + for _, filter in ipairs(query.stats[1].filters) do + filtersById[filter.id] = filter + end + assert.are.equal(31, #query.stats[1].filters) + assert.are.equal(35.5, filtersById["pseudo.pseudo_number_of_empty_affix_mods"].value.weight) + assert.is_not_nil(filtersById["explicit.stat_30"]) + assert.is_nil(filtersById["explicit.stat_31"]) + assert.are.equal(receivedPlan, queryGen.requesterContext.benchCraftEvaluationPlan) + end) end) end) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 934187c19ec..ce3bae99d42 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -6,6 +6,21 @@ describe("TradeQuery", function() mock_tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) end) + describe("result sorting", function() + it("ignores row metadata in Highest Weight order", function() + mock_tradeQuery.resultTbl[1] = { { }, { }, { } } + mock_tradeQuery.resultTbl[1].benchCraftEvaluationPlan = { } + mock_tradeQuery.sortModes = { Weight = "(Highest) Weighted Sum" } + + local sortedResults = mock_tradeQuery:SortFetchResults(1, mock_tradeQuery.sortModes.Weight) + + assert.are.equal(3, #sortedResults) + for index, result in ipairs(sortedResults) do + assert.are.equal("number", type(result.index)) + assert.are.equal(index, result.index) + end + end) + end) describe("cooperative result evaluation", function() it("resumes fetched result work over multiple frames", function() local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) @@ -393,16 +408,16 @@ describe("TradeQuery", function() return table.concat(lines, "\n") end - local function evaluate(itemString, crafts, calcOverride, yieldFunc, weightSnapshot) + local function evaluate(itemString, crafts, calcOverride, yieldFunc, evaluationPlan) local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = { } }) tradeQuery.tradeQueryGenerator = mock_queryGen tradeQuery.itemsTab.build = { data = { masterMods = crafts or { prefixCraft, suffixCraft } } } tradeQuery.statSortSelectionList = { { stat = "Life", weightMult = 1 } } tradeQuery.slotTables[1] = { slotName = "Ring 1", considerBenchCraft = true } - tradeQuery.resultTbl[1] = { { - item_string = itemString, - benchCraftWeightSnapshot = weightSnapshot, - } } + tradeQuery.resultTbl[1] = { + { item_string = itemString }, + benchCraftEvaluationPlan = evaluationPlan, + } local function calc(args) local life = 100 for _, modLine in ipairs(args.repItem.explicitModLines or { }) do @@ -518,7 +533,7 @@ describe("TradeQuery", function() assert.is_nil(evaluation.benchCraftItemString:find("10% increased Rarity", 1, true)) end) - it("tracks the replacement preview line after a full item reparse", function() + it("tracks the replacement preview line with catalyst scaling", function() local itemString = makeRareRing(3, 2, { "{crafted}{suffix}+20 to Dexterity" }) :gsub("Implicits: 0", "Catalyst: Intrinsic\nCatalystQuality: 20\nImplicits: 0") local evaluation = evaluate(itemString, { suffixCraft }) @@ -565,6 +580,28 @@ describe("TradeQuery", function() assert.is_nil(evaluation.benchCraft) end) + it("blocks a craft when a differently worded explicit mod occupies its group", function() + local minionCountCraft = { + type = "Prefix", + group = "MaximumMinionCount", + types = { Helmet = true }, + "+1 to maximum number of Raised Zombies", + "+1 to maximum number of Skeletons", + } + local itemString = table.concat({ + "Rarity: Rare", + "Test Helmet", + "Hubris Circlet", + "Implicits: 0", + "{prefix}+1 to maximum number of Spectres", + }, "\n") + + local evaluation = evaluate(itemString, { minionCountCraft }) + + assert.are.equal(1, evaluation.weight) + assert.is_nil(evaluation.benchCraft) + end) + it("does not evaluate an item when an explicit affix side is unknown", function() local evaluation = evaluate(makeRareRing(0, 0, { "+50 to maximum Life" }), { suffixCraft }) @@ -661,13 +698,12 @@ describe("TradeQuery", function() statOrder = { 2 }, "+(2-2) to Dexterity", } - local weightSnapshot = { - modWeights = { { tradeModId = "explicit.test", weight = 1 } }, + local evaluationPlan = { statWeights = { { stat = "Life", weightMult = 1 } }, } local originalEstimator = mock_queryGen.EstimateBenchCraftWeight - mock_queryGen.EstimateBenchCraftWeight = function(_, craft, receivedSnapshot) - assert.are.equal(weightSnapshot, receivedSnapshot) + mock_queryGen.EstimateBenchCraftWeight = function(_, craft, receivedPlan) + assert.are.equal(evaluationPlan, receivedPlan) return craft == predictedBest and 2 or 1 end local calls = 0 @@ -681,13 +717,92 @@ describe("TradeQuery", function() end end return { Life = 100 } - end, nil, weightSnapshot) + end, nil, evaluationPlan) mock_queryGen.EstimateBenchCraftWeight = originalEstimator assert.are.equal(2, calls) assert.is_truthy(evaluation.benchCraft:find("Strength", 1, true)) end) + it("evaluates the best general craft and each highest-level local group", function() + local localStunLow = { + type = "Prefix", group = "LocalStunDuration", level = 20, types = { Ring = true }, + "11% increased Stun Duration on Enemies", + } + local localStunHigh = { + type = "Prefix", group = "LocalStunDuration", level = 40, types = { Ring = true }, + "22% increased Stun Duration on Enemies", + } + local localEnergyShield = { + type = "Prefix", group = "LocalIncreasedEnergyShield", level = 30, types = { Ring = true }, + "+10 to maximum Energy Shield", + } + local generalBest = { + type = "Prefix", group = "IncreasedLife", level = 30, types = { Ring = true }, + "+20 to maximum Life", + } + local generalWorse = { + type = "Prefix", group = "IncreasedMana", level = 30, types = { Ring = true }, + "+20 to maximum Mana", + } + local evaluationPlan = { + statWeights = { { stat = "Life", weightMult = 1 } }, + } + local originalEstimator = mock_queryGen.EstimateBenchCraftWeight + mock_queryGen.EstimateBenchCraftWeight = function(_, craft) + return craft == generalBest and 2 or 1 + end + local evaluatedLines = { } + evaluate(makeRareRing(2, 3), { + localStunLow, localStunHigh, localEnergyShield, generalWorse, generalBest, + }, function(args) + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted then + evaluatedLines[modLine.line] = true + end + end + return { Life = 100 } + end, nil, evaluationPlan) + mock_queryGen.EstimateBenchCraftWeight = originalEstimator + + assert.is_nil(evaluatedLines["11% increased Stun Duration on Enemies"]) + assert.is_true(evaluatedLines["22% increased Stun Duration on Enemies"]) + assert.is_true(evaluatedLines["+10 to maximum Energy Shield"]) + assert.is_true(evaluatedLines["+20 to maximum Life"]) + assert.is_nil(evaluatedLines["+20 to maximum Mana"]) + end) + + it("uses item scaling when selecting the best general craft", function() + local attributeCraft = { + type = "Suffix", group = "Strength", modTags = { "attribute" }, types = { Ring = true }, + "+20 to Strength", + } + local lifeCraft = { + type = "Suffix", group = "LifeRegeneration", modTags = { "life" }, types = { Ring = true }, + "Regenerate 20 Life per second", + } + local evaluationPlan = { + statWeights = { { stat = "Life", weightMult = 1 } }, + } + local originalEstimator = mock_queryGen.EstimateBenchCraftWeight + mock_queryGen.EstimateBenchCraftWeight = function(_, craft, _, valueScalar) + return (craft == attributeCraft and 100 or 110) * valueScalar + end + local itemString = makeRareRing(3, 2) + :gsub("Implicits: 0", "Catalyst: Intrinsic\nCatalystQuality: 20\nImplicits: 0") + local evaluation = evaluate(itemString, { lifeCraft, attributeCraft }, function(args) + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted and modLine.line:find("Strength", 1, true) then + return { Life = 200 } + end + end + return { Life = 100 } + end, nil, evaluationPlan) + mock_queryGen.EstimateBenchCraftWeight = originalEstimator + + assert.is_truthy(evaluation.benchCraft:find("Strength", 1, true)) + end) + it("falls back to exhaustive evaluation when stat weights changed after the query", function() local strengthCraft = { type = "Suffix", group = "Strength", types = { Ring = true }, "+1 to Strength", @@ -695,13 +810,12 @@ describe("TradeQuery", function() local dexterityCraft = { type = "Suffix", group = "Dexterity", types = { Ring = true }, "+2 to Dexterity", } - local staleSnapshot = { - modWeights = { { tradeModId = "explicit.test", weight = 1 } }, + local stalePlan = { statWeights = { { stat = "Life", weightMult = 2 } }, } local originalEstimator = mock_queryGen.EstimateBenchCraftWeight - mock_queryGen.EstimateBenchCraftWeight = function(_, _, receivedSnapshot) - assert.is_nil(receivedSnapshot) + mock_queryGen.EstimateBenchCraftWeight = function(_, _, receivedPlan) + assert.is_nil(receivedPlan) end local calls = 0 local evaluation = evaluate(makeRareRing(3, 2), { strengthCraft, dexterityCraft }, function(args) @@ -714,7 +828,7 @@ describe("TradeQuery", function() end end return { Life = 100 } - end, nil, staleSnapshot) + end, nil, stalePlan) mock_queryGen.EstimateBenchCraftWeight = originalEstimator assert.are.equal(3, calls) @@ -722,7 +836,7 @@ describe("TradeQuery", function() assert.is_truthy(evaluation.benchCraft:find("Dexterity", 1, true)) end) - it("keeps lower bench tiers when a higher tier has a worse trade-off", function() + it("evaluates only the highest-level craft in a group", function() local crafts = { { type = "Suffix", group = "FlaskEffectAndFlaskChargesGained", level = 60, types = { Ring = true }, @@ -739,13 +853,13 @@ describe("TradeQuery", function() if modLine.crafted and modLine.line:find("20% reduced", 1, true) then life = 200 elseif modLine.crafted and modLine.line:find("33% reduced", 1, true) then - life = 50 + life = 150 end end return { Life = life } end) - assert.is_truthy(evaluation.benchCraft:find("20% reduced", 1, true)) + assert.is_truthy(evaluation.benchCraft:find("33% reduced", 1, true)) end) it("renders every line of a multi-line craft in the Ctrl preview", function() diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index 3197074d831..83b6e806f69 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -61,6 +61,40 @@ local function getCatalystScalar(catalystId, mod, quality) return 1 end +local function modMatchesMagnitude(mod, modType, magnitudeMod) + if magnitudeMod.modType and magnitudeMod.modType ~= modType then + return false + end + local tagLookup = { } + for _, tag in ipairs(mod.modTags or { }) do + tagLookup[tag] = true + end + -- These are not actual mod tags, but do appear in modifier magnitude mods. + for _, lineFlag in ipairs({ "unveiled", "prefix", "suffix" }) do + if mod[lineFlag] then + tagLookup[lineFlag] = true + end + end + for _, tag in ipairs(magnitudeMod.tags or { }) do + if not tagLookup[tag] then + return false + end + end + if magnitudeMod.anyTags then + local anyTagMatches = false + for _, tag in ipairs(magnitudeMod.anyTags) do + if tagLookup[tag] then + anyTagMatches = true + break + end + end + if not anyTagMatches then + return false + end + end + return true +end + local function normaliseModLine(line) return line:gsub("%d+%.?%d*", "#") :gsub("%(%-?#%-#%)", "#"):lower() @@ -98,6 +132,24 @@ function ItemClass:Item(raw, rarity, highQuality) return self end +-- Accepts parsed lines and proposed mods; modType selects applicable modifier-magnitude effects. +function ItemClass:GetModLineValueScalar(mod, modType) + local valueScalar = getCatalystScalar(self.catalyst, mod, self.catalystQuality) + if mod.unscalable then + return valueScalar + end + for _, magnitudeMod in ipairs(self.modMagnitudeMods or { }) do + if modMatchesMagnitude(mod, modType, magnitudeMod) then + if magnitudeMod.multiplier then + valueScalar = valueScalar * magnitudeMod.multiplier + else + valueScalar = valueScalar + magnitudeMod.quality / 100 + end + end + end + return valueScalar +end + -- Reset all influence keys to false function ItemClass:ResetInfluence() for _, curInfluenceInfo in ipairs(influenceInfo) do @@ -1381,54 +1433,22 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) if self.advancedCopy or self.crafted then -- apply mod magnitude boost to matching mods if #self.modMagnitudeMods > 0 then - for _, modMagnitudeMod in ipairs(self.modMagnitudeMods) do - local modLists - if modMagnitudeMod.modType then - modLists = { self[modMagnitudeMod.modType .. "ModLines"] } - else - modLists = { self.implicitModLines, self.explicitModLines, self.enchantModLines } - end - for _, mods in ipairs(modLists) do - for _, mod in ipairs(mods or {}) do - -- avoid scaling variant lines which are not active - if mod.variantList and (self:GetModLineVariantCount(mod) == 0) then - goto modMagnitudeContinue - end - -- Create a fast lookup table for all provided tags - local tagLookup = {} - for _, curTag in ipairs(mod.modTags) do - tagLookup[curTag] = true; - end - -- these aren't actual mod tags but do appear in mod magnitude mods - for _, lineFlag in ipairs({ "unveiled", "prefix", "suffix" }) do - if mod[lineFlag] then - tagLookup[lineFlag] = true - end - end - local match = true - for _, magnitudeTag in ipairs(modMagnitudeMod.tags) do - if not tagLookup[magnitudeTag] then - match = false - end - end - if modMagnitudeMod.anyTags and not (tagLookup[modMagnitudeMod.anyTags[1]] or tagLookup[modMagnitudeMod.anyTags[2]]) then - match = false - end - if match and not mod.unscalable then - if modMagnitudeMod.multiplier then - mod.valueScalar = (mod.valueScalar or 1) * modMagnitudeMod.multiplier - else - mod.valueScalar = (mod.valueScalar or 1) + (modMagnitudeMod.quality / 100) - end - end - if mod.valueScalar and mod.valueScalar ~= 1 then + for _, entry in ipairs({ + { modType = "implicit", mods = self.implicitModLines }, + { modType = "explicit", mods = self.explicitModLines }, + { modType = "enchant", mods = self.enchantModLines }, + }) do + for _, mod in ipairs(entry.mods or { }) do + -- Avoid scaling variant lines which are not active. + if not mod.variantList or self:GetModLineVariantCount(mod) > 0 then + mod.valueScalar = self:GetModLineValueScalar(mod, entry.modType) + if mod.valueScalar ~= 1 then local rangedLine = itemLib.applyRange(mod.line, mod.range or 1, mod.valueScalar, 1) local modList, extra = modLib.parseMod(rangedLine) mod.displayValueScalar = 1 mod.modList = modList mod.extra = extra end - ::modMagnitudeContinue:: end end end diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 76be428105c..c823aa53f67 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -1021,7 +1021,7 @@ function TradeQueryClass:GetBenchCraftAvailability(item) }, craftState end -local function getExistingAffixGroups(item, existingLines) +local function getExistingAffixGroups(item, existingLines, benchGroups) local groups = { } for _, side in ipairs({ "prefixes", "suffixes" }) do for _, affix in ipairs(item[side] or { }) do @@ -1032,7 +1032,8 @@ local function getExistingAffixGroups(item, existingLines) end end for _, mod in pairs(item.affixes or { }) do - if mod.group then + -- Unique, corrupted and other non-affix data cannot occupy a bench slot. + if (mod.type == "Prefix" or mod.type == "Suffix") and benchGroups[mod.group] then for _, line in ipairs(mod) do if existingLines[normaliseBenchCraftLine(line)] then groups[mod.group] = true @@ -1092,7 +1093,53 @@ local function getItemWithoutCraftedMods(item) return new("Item"):Item(strippedItem:BuildRaw()), replacedCraft end -function TradeQueryClass:GetBestBenchCraftEvaluation(item, slotName, calcFunc, baseOutput, output, weight, weightSnapshot, yieldFunc) +function TradeQueryClass:GetBenchCraftsToEvaluate(item, available, evaluationPlan) + local existingLines = getExistingModLines(item) + local benchCrafts = self.itemsTab.build.data.masterMods or { } + local benchGroups = { } + for _, craft in ipairs(benchCrafts) do + if craft.group then + benchGroups[craft.group] = true + end + end + local existingGroups = getExistingAffixGroups(item, existingLines, benchGroups) + local legalCrafts = { } + for _, craft in ipairs(benchCrafts) do + if available[craft.type] and available[craft.type] > 0 + and craft.types and craft.types[item.type] + and not conflictsWithExistingAffix(craft, existingGroups, existingLines) then + t_insert(legalCrafts, craft) + end + end + legalCrafts = self.tradeQueryGenerator:GetHighestLevelBenchCrafts(legalCrafts) + if not evaluationPlan then + return legalCrafts + end + + local candidates = { } + local bestEstimatedCraft + local bestEstimatedWeight + -- Local groups depend on concrete item properties and require exact evaluation. + -- For other groups, evaluate only the best estimate from the generated query plan. + for _, craft in ipairs(legalCrafts) do + if self.tradeQueryGenerator:IsLocalBenchCraft(craft) then + t_insert(candidates, craft) + else + local valueScalar = self.tradeQueryGenerator:GetBenchCraftValueScalar(item, craft) + local estimatedWeight = self.tradeQueryGenerator:EstimateBenchCraftWeight(craft, evaluationPlan, valueScalar) + if estimatedWeight and (not bestEstimatedWeight or estimatedWeight > bestEstimatedWeight) then + bestEstimatedCraft = craft + bestEstimatedWeight = estimatedWeight + end + end + end + if bestEstimatedCraft and bestEstimatedWeight > 0 then + t_insert(candidates, bestEstimatedCraft) + end + return candidates +end + +function TradeQueryClass:GetBestBenchCraftEvaluation(item, slotName, calcFunc, baseOutput, output, weight, evaluationPlan, yieldFunc) local available, craftState = self:GetBenchCraftAvailability(item) local evaluationItem = item local replacedCraft @@ -1106,90 +1153,59 @@ function TradeQueryClass:GetBestBenchCraftEvaluation(item, slotName, calcFunc, b if not available or (available.Prefix == 0 and available.Suffix == 0) then return output, weight end - local existingLines = getExistingModLines(evaluationItem) - local existingGroups = getExistingAffixGroups(evaluationItem, existingLines) local bestCraft local bestCraftItemString local bestCraftLineIndexes local bestReplacedCraft - local originalItem = evaluationItem:BuildRaw() - local craftedItem = new("Item"):Item(originalItem) - local requiresFullParse = #craftedItem.modMagnitudeMods > 0 or (craftedItem.catalyst and craftedItem.catalyst > 0) - local legalCrafts = { } - local bestEstimatedCraft - local bestEstimatedWeight - for _, craft in ipairs(self.itemsTab.build.data.masterMods or { }) do - if available[craft.type] and available[craft.type] > 0 - and craft.types and craft.types[evaluationItem.type] - and not conflictsWithExistingAffix(craft, existingGroups, existingLines) then - t_insert(legalCrafts, craft) - local estimatedWeight = self.tradeQueryGenerator:EstimateBenchCraftWeight(craft, weightSnapshot) - if estimatedWeight and (not bestEstimatedWeight or estimatedWeight > bestEstimatedWeight) then - bestEstimatedCraft = craft - bestEstimatedWeight = estimatedWeight - end - end - end - -- Generated queries already have marginal mod weights. Use them to choose one - -- concrete legal craft; pasted queries without weights retain the exact fallback. - if bestEstimatedCraft then - legalCrafts = bestEstimatedWeight > 0 and { bestEstimatedCraft } or { } - end + local legalCrafts = self:GetBenchCraftsToEvaluate(evaluationItem, available, evaluationPlan) + local craftedItem = new("Item"):Item(evaluationItem:BuildRaw()) for _, craft in ipairs(legalCrafts) do - local firstCraftLineIndex = #craftedItem.explicitModLines + 1 - for _, line in ipairs(craft) do - local modList, extra - if not requiresFullParse then - local rangedLine = itemLib.applyRange(line, main.defaultItemAffixQuality or 0.5, 1, 1) - modList, extra = modLib.parseMod(rangedLine) - end - t_insert(craftedItem.explicitModLines, { - line = line, - modList = modList, - extra = extra, - range = main.defaultItemAffixQuality or 0.5, - modTags = craft.modTags, - modGroup = craft.group, - crafted = true, - prefix = craft.type == "Prefix", - suffix = craft.type == "Suffix", - }) - end - if requiresFullParse then - craftedItem:BuildAndParseRaw() - else - craftedItem:BuildModList() - end - local craftOutput = self:ReduceOutput(calcFunc({ repSlotName = slotName, repItem = craftedItem })) - if yieldFunc then - yieldFunc() - end - local craftWeight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, craftOutput, self.statSortSelectionList) - if craftWeight > weight then - output = craftOutput - weight = craftWeight - bestCraft = table.concat(craft, "/") .. " ^8(" .. craft.type .. ")" - bestCraftItemString = craftedItem:BuildRaw() - bestReplacedCraft = replacedCraft - bestCraftLineIndexes = { } - for lineIndex = firstCraftLineIndex, #craftedItem.explicitModLines do - t_insert(bestCraftLineIndexes, lineIndex) - end - end - if requiresFullParse then - craftedItem = new("Item"):Item(originalItem) - else - for _ = 1, #craft do - t_remove(craftedItem.explicitModLines, #craftedItem.explicitModLines) - end + local firstCraftLineIndex = #craftedItem.explicitModLines + 1 + local valueScalar = self.tradeQueryGenerator:GetBenchCraftValueScalar(craftedItem, craft) + for _, line in ipairs(craft) do + local rangedLine = itemLib.applyRange(line, main.defaultItemAffixQuality or 0.5, valueScalar, 1) + local modList, extra = modLib.parseMod(rangedLine) + t_insert(craftedItem.explicitModLines, { + line = line, + modList = modList, + extra = extra, + range = main.defaultItemAffixQuality or 0.5, + valueScalar = valueScalar, + modTags = craft.modTags, + modGroup = craft.group, + crafted = true, + prefix = craft.type == "Prefix", + suffix = craft.type == "Suffix", + }) + end + craftedItem:BuildModList() + local craftOutput = self:ReduceOutput(calcFunc({ repSlotName = slotName, repItem = craftedItem })) + if yieldFunc then + yieldFunc() + end + local craftWeight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, craftOutput, self.statSortSelectionList) + if craftWeight > weight then + output = craftOutput + weight = craftWeight + bestCraft = table.concat(craft, "/") .. " ^8(" .. craft.type .. ")" + bestCraftItemString = craftedItem:BuildRaw() + bestReplacedCraft = replacedCraft + bestCraftLineIndexes = { } + for lineIndex = firstCraftLineIndex, #craftedItem.explicitModLines do + t_insert(bestCraftLineIndexes, lineIndex) end + end + for _ = 1, #craft do + t_remove(craftedItem.explicitModLines, #craftedItem.explicitModLines) + end end return output, weight, bestCraft, bestCraftItemString, bestCraftLineIndexes, bestReplacedCraft end -- Method to evaluate a result by getting it's output and weight function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, baseOutput, yieldFunc) - local result = self.resultTbl[row_idx][result_index] + local resultRow = self.resultTbl[row_idx] + local result = resultRow[result_index] if not calcFunc then calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() end @@ -1250,11 +1266,11 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba local weight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, output, self.statSortSelectionList) local benchCraft, benchCraftItemString, benchCraftLineIndexes, benchCraftReplaced if slotTbl.considerBenchCraft then - local weightSnapshot = result.benchCraftWeightSnapshot - if weightSnapshot and not tableDeepEquals(self.statSortSelectionList, weightSnapshot.statWeights) then - weightSnapshot = nil + local evaluationPlan = resultRow.benchCraftEvaluationPlan + if evaluationPlan and not tableDeepEquals(self.statSortSelectionList, evaluationPlan.statWeights) then + evaluationPlan = nil end - output, weight, benchCraft, benchCraftItemString, benchCraftLineIndexes, benchCraftReplaced = self:GetBestBenchCraftEvaluation(item, slotName, calcFunc, baseOutput, output, weight, weightSnapshot, yieldFunc) + output, weight, benchCraft, benchCraftItemString, benchCraftLineIndexes, benchCraftReplaced = self:GetBestBenchCraftEvaluation(item, slotName, calcFunc, baseOutput, output, weight, evaluationPlan, yieldFunc) end result.evaluation = {{ output = output, @@ -1379,7 +1395,7 @@ function TradeQueryClass:SortFetchResults(row_idx, mode, yieldFunc) end local newTbl = {} if mode == self.sortModes.Weight then - for index, _ in pairs(self.resultTbl[row_idx]) do + for index = 1, #self.resultTbl[row_idx] do t_insert(newTbl, { outputAttr = index, index = index }) end return newTbl @@ -1503,9 +1519,9 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro item.enchantModLines = {} end itemsSafe[i].item_string = item:BuildRaw() - itemsSafe[i].benchCraftWeightSnapshot = context.benchCraftWeightSnapshot end + itemsSafe.benchCraftEvaluationPlan = context.benchCraftEvaluationPlan self.resultTbl[context.row_idx] = itemsSafe self:StartResultEvaluation(context.row_idx) end, diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index c3f2a31a3ea..aeb32e653b2 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -204,36 +204,65 @@ function TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, newOutput, st return meanStatDiff end -function TradeQueryGeneratorClass:CreateBenchCraftWeightSnapshot(statWeights) +function TradeQueryGeneratorClass:CreateBenchCraftEvaluationPlan(statWeights) if not self.modWeights or #self.modWeights == 0 then return end + local weightsByTradeMod = { } + for _, entry in ipairs(self.modWeights) do + weightsByTradeMod[entry.tradeModId] = copyTable(entry) + end return { - modWeights = copyTable(self.modWeights), statWeights = copyTable(statWeights or { }), + weightsByTradeMod = weightsByTradeMod, + craftWeights = { }, } end -function TradeQueryGeneratorClass:EstimateBenchCraftWeight(craft, weightSnapshot) - local weightSource = weightSnapshot and weightSnapshot.modWeights - if not weightSource then - return nil - end - self.benchCraftWeightCaches = self.benchCraftWeightCaches or { } - if not self.benchCraftWeightCaches[weightSource] then - local weightsByTradeMod = { } - for _, entry in ipairs(weightSource) do - weightsByTradeMod[entry.tradeModId] = entry +function TradeQueryGeneratorClass:GetHighestLevelBenchCrafts(crafts) + local craftByGroup = { } + local groupOrder = { } + for _, craft in ipairs(crafts) do + local group = craft.group or craft + if not craftByGroup[group] then + t_insert(groupOrder, group) end - self.benchCraftWeightCaches[weightSource] = { - weightsByTradeMod = weightsByTradeMod, - craftWeights = { }, - } + if not craftByGroup[group] or (craft.level or 0) > (craftByGroup[group].level or 0) then + craftByGroup[group] = craft + end + end + local highestLevelCrafts = { } + for _, group in ipairs(groupOrder) do + t_insert(highestLevelCrafts, craftByGroup[group]) end - local cache = self.benchCraftWeightCaches[weightSource] - local cached = cache.craftWeights[craft] + return highestLevelCrafts +end + +function TradeQueryGeneratorClass:IsLocalBenchCraft(craft) + -- Generated local modifier groups change the concrete item's weapon or defence values. + return craft.group and craft.group:find("^Local") ~= nil +end + +function TradeQueryGeneratorClass:GetBenchCraftValueScalar(item, craft) + if not item or not item.GetModLineValueScalar then + return 1 + end + return item:GetModLineValueScalar({ + modTags = craft.modTags, + unscalable = craft.unscalable, + prefix = craft.type == "Prefix", + suffix = craft.type == "Suffix", + }, "explicit") +end + +function TradeQueryGeneratorClass:EstimateBenchCraftWeight(craft, evaluationPlan, valueScalar) + if not evaluationPlan then + return nil + end + valueScalar = valueScalar or 1 + local cached = evaluationPlan.craftWeights[craft] if cached ~= nil then - return cached or nil + return cached and cached * valueScalar or nil end local score = 0 @@ -242,7 +271,7 @@ function TradeQueryGeneratorClass:EstimateBenchCraftWeight(craft, weightSnapshot local statOrder = craft.statOrder and craft.statOrder[index] local explicitMods = self.modData and self.modData.Explicit local modEntry = statOrder and explicitMods and explicitMods[tostring(statOrder) .. "_" .. craft.group] - local weightEntry = modEntry and cache.weightsByTradeMod[modEntry.tradeMod.id] + local weightEntry = modEntry and evaluationPlan.weightsByTradeMod[modEntry.tradeMod.id] if weightEntry then local rangedLine = itemLib.applyRange(line, main.defaultItemAffixQuality or 0.5, 1, 1) local _, value = tradeHelpers.findTradeHash(rangedLine) @@ -258,8 +287,42 @@ function TradeQueryGeneratorClass:EstimateBenchCraftWeight(craft, weightSnapshot end end end - cache.craftWeights[craft] = matchedWeight and score or false - return matchedWeight and score or nil + evaluationPlan.craftWeights[craft] = matchedWeight and score or false + return matchedWeight and score * valueScalar or nil +end + +function TradeQueryGeneratorClass:GetBenchCraftQueryWeight(item, evaluationPlan) + if not item or not item.type or not evaluationPlan then + return + end + local compatibleCrafts = { } + local masterMods = self.itemsTab and self.itemsTab.build and self.itemsTab.build.data + and self.itemsTab.build.data.masterMods or { } + for _, craft in ipairs(masterMods) do + if (craft.type == "Prefix" or craft.type == "Suffix") and craft.types and craft.types[item.type] then + t_insert(compatibleCrafts, craft) + end + end + local bestWeight + for _, craft in ipairs(self:GetHighestLevelBenchCrafts(compatibleCrafts)) do + local valueScalar = self:GetBenchCraftValueScalar(item, craft) + local weight = self:EstimateBenchCraftWeight(craft, evaluationPlan, valueScalar) + if weight and weight > (bestWeight or 0) then + bestWeight = weight + end + end + return bestWeight +end + +function TradeQueryGeneratorClass:GetBenchCraftQueryFilter(item, evaluationPlan) + local weight = self:GetBenchCraftQueryWeight(item, evaluationPlan) + if not weight then + return + end + return { + id = "pseudo.pseudo_number_of_empty_affix_mods", + value = { min = 1, max = 1, weight = weight }, + }, weight end function TradeQueryGeneratorClass:ProcessMod(modId, mod, tradeQueryStatsParsed, itemCategoriesMask, itemCategoriesOverride) @@ -803,7 +866,6 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) -- Test each mod one at a time and cache the normalized Stat (configured earlier) diff to use as weight self.modWeights = { } - self.benchCraftWeightCaches = nil self.alreadyWeightedMods = { } self.calcContext = { @@ -945,6 +1007,9 @@ function TradeQueryGeneratorClass:FinishQuery() end return a.meanStatDiff > b.meanStatDiff end) + local benchCraftEvaluationPlan = self.requesterContext and self.requesterContext.slotTbl + and self.requesterContext.slotTbl.considerBenchCraft + and self:CreateBenchCraftEvaluationPlan(self.calcContext.options.statWeights) or nil -- A megalomaniac is not being compared to anything and the currentStatDiff will be 0, so just go for an arbitrary min weight - in this case triple the weight of the worst evaluated node. local megalomaniacSpecialMinWeight = self.calcContext.special.itemName == "Megalomaniac" and self.modWeights[#self.modWeights] * 3 @@ -1050,7 +1115,16 @@ function TradeQueryGeneratorClass:FinishQuery() ignoredStats[tostring(HashStats(stats))] = true end end - local statFilters = {} + local statFilterEntries = { } + local function addStatFilter(filterEntry, priority) + local entry = { + filter = filterEntry, + priority = priority or 0, + order = #statFilterEntries + 1, + } + t_insert(statFilterEntries, entry) + return entry + end local pseudoMods = {} for _, entry in ipairs(self.modWeights) do local hash = entry.tradeModId:match("stat_(%d+)") @@ -1064,17 +1138,19 @@ function TradeQueryGeneratorClass:FinishQuery() filterEntry.id = tradeId -- avoid adding duplicate pseudo filters: update existing if pseudoMods[tradeId] then - pseudoMods[tradeId].value.weight = math.max(filterEntry.value.weight, pseudoMods[tradeId].value.weight) + local existingEntry = pseudoMods[tradeId] + existingEntry.filter.value.weight = math.max(filterEntry.value.weight, existingEntry.filter.value.weight) + existingEntry.priority = m_max(existingEntry.priority, entry.meanStatDiff or 0) else - pseudoMods[tradeId] = filterEntry - table.insert(statFilters, filterEntry) + pseudoMods[tradeId] = addStatFilter(filterEntry, entry.meanStatDiff) end else - table.insert(statFilters, filterEntry) + addStatFilter(filterEntry, entry.meanStatDiff) end ::weightContinue:: end + local benchCraftQueryFilter, benchCraftQueryPriority = self:GetBenchCraftQueryFilter(self.calcContext.testItem, benchCraftEvaluationPlan) for k, v in pairs(self.calcContext.special.queryExtra or {}) do queryTable.query[k] = v @@ -1095,8 +1171,17 @@ function TradeQueryGeneratorClass:FinishQuery() t_insert(queryTable.query.stats, andFilters) end - for _, entry in ipairs(statFilters) do - t_insert(queryTable.query.stats[1].filters, entry) + if benchCraftQueryFilter then + addStatFilter(benchCraftQueryFilter, benchCraftQueryPriority) + end + table.sort(statFilterEntries, function(a, b) + if a.priority == b.priority then + return a.order < b.order + end + return a.priority > b.priority + end) + for _, entry in ipairs(statFilterEntries) do + t_insert(queryTable.query.stats[1].filters, entry.filter) filters = filters + 1 if filters == effective_max then break @@ -1179,9 +1264,7 @@ function TradeQueryGeneratorClass:FinishQuery() local queryJson = dkjson.encode(queryTable) if self.requesterContext then - self.requesterContext.benchCraftWeightSnapshot = self.requesterContext.slotTbl - and self.requesterContext.slotTbl.considerBenchCraft - and self:CreateBenchCraftWeightSnapshot(self.calcContext.options.statWeights) or nil + self.requesterContext.benchCraftEvaluationPlan = benchCraftEvaluationPlan end self.requesterCallback(self.requesterContext, queryJson, errMsg) @@ -1253,7 +1336,7 @@ function TradeQueryGeneratorClass:RequestQuery(slot, context, statWeights, callb if supportsBenchCraft then controls.considerBenchCraft = new("CheckBoxControl"):CheckBoxControl({ "TOPRIGHT", lastItemAnchor, "BOTTOMRIGHT" }, { 0, 5, 18 }, "Bench Craft:", function(state) end, - "Sorts fetched results using the highest estimated-weight legal bench craft or replacement.") + "Searches for one empty affix and ranks each result using its best compatible bench craft or replacement.") controls.considerBenchCraft.state = self.lastConsiderBenchCraft == true updateLastAnchor(controls.considerBenchCraft) end @@ -1289,7 +1372,7 @@ Remove: eldritch implicits are removed and ignored in the search.]] controls.includeEldritch = new("DropDownControl"):DropDownControl({ "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 140, 18 }, { "Copy Current", "Keep regular", "Keep regular+presence", "Remove" }, function(_state) end, eldritchTooltip) controls.includeEldritchLabel = new("LabelControl"):LabelControl({ "RIGHT", controls.includeEldritch, "LEFT" }, - { -4, 0, 80, 16 }, "Eldritch Mods:") + { -4, 0, 80, 16 }, "^7Eldritch Mods:") controls.includeEldritch:SelByValue(self.lastIncludeEldritch) updateLastAnchor(controls.includeEldritch) end