From f6cf8ec03c0fdfb9e9323aaaacf490f1956cd910 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 14:46:33 +0200 Subject: [PATCH 01/16] 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 ccb4fc6c51..0ea2e484ed 100644 --- a/spec/System/TestTradeQueryRequests_spec.lua +++ b/spec/System/TestTradeQueryRequests_spec.lua @@ -281,6 +281,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("SearchWithURL", function() diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 9546edac43..98e4f7c3aa 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("trade search links", function() local dkjson = require "dkjson" @@ -260,4 +331,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 1d0efbbeb8..172aea85f2 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -804,6 +804,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 = {} @@ -818,6 +822,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] @@ -875,7 +1069,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 @@ -1236,6 +1440,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 @@ -1248,9 +1487,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 097a2e7f94..547383b303 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1235,6 +1235,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) @@ -1376,6 +1386,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 0c93599196..9aa166910f 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -359,13 +359,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)) @@ -379,7 +439,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 for _, influenceInfo in ipairs(itemLib.influenceInfo.all) do local apiKey = tradeInfluenceApiKeys[influenceInfo.key] From eeb7bfe260ffc5d98239641d3a3c3d17197bb3fa Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 16:11:32 +0200 Subject: [PATCH 02/16] 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 98e4f7c3aa..1688158904 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -393,7 +393,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) @@ -402,19 +402,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 @@ -424,7 +424,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) @@ -453,7 +453,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) @@ -468,21 +468,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", @@ -537,11 +537,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", }, } @@ -562,7 +562,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 547383b303..6c41204ce2 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1240,7 +1240,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 2b17bd85871923906dc52db5e70595a9b4ea1a09 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 17:56:19 +0200 Subject: [PATCH 03/16] 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 1688158904..19b925b225 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -434,7 +434,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 }) @@ -442,7 +442,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 9cb0d93e63478c952e009ceb10d36df5fa123e16 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 18:09:28 +0200 Subject: [PATCH 04/16] 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 19b925b225..445508b515 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -409,6 +409,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 8826762c4eb88ff252acdb16f6a175186ba64358 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 18:51:57 +0200 Subject: [PATCH 05/16] 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 445508b515..ba3e60e927 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] = { @@ -442,10 +470,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() @@ -454,6 +515,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 172aea85f2..7ec4ba6c4d 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -893,13 +893,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) @@ -947,22 +951,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 @@ -995,6 +1035,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) @@ -1009,7 +1050,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 @@ -1069,9 +1110,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, @@ -1079,6 +1120,7 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba benchCraft = benchCraft, benchCraftItemString = benchCraftItemString, benchCraftLineIndexes = benchCraftLineIndexes, + benchCraftReplaced = benchCraftReplaced, }} end return result.evaluation @@ -1446,8 +1488,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 6c41204ce2..9be1159bf0 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1239,8 +1239,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 18be6a4a1e6d32d8dbd038b829d9b768aa3b3c6d Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 10 Aug 2026 01:12:44 +0200 Subject: [PATCH 06/16] 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 4f3c035b5e..129a96d4c5 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -165,6 +165,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 ba3e60e927..a0d315a75a 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -400,13 +400,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 @@ -418,7 +421,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() @@ -437,6 +440,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", @@ -610,6 +638,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 7ec4ba6c4d..9aa40c99e6 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -977,7 +977,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 @@ -1000,10 +1000,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 @@ -1029,6 +1046,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 @@ -1048,30 +1068,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 if slotTbl.slotName == "Megalomaniac" then @@ -1079,10 +1101,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 @@ -1109,10 +1138,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, @@ -1350,6 +1386,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 9be1159bf0..fc46dae51c 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -198,6 +198,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 @@ -771,6 +829,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 = { @@ -1155,6 +1214,11 @@ function TradeQueryGeneratorClass:FinishQuery() errMsg = "Could not generate search, found no mods to search for" end + 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, queryTable, errMsg) -- Close blocker popup @@ -1240,7 +1304,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 e927ffd9fb24d1eed911fd75f6b093c4de50f39e Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Wed, 12 Aug 2026 00:38:37 +0200 Subject: [PATCH 07/16] 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 | 253 ++++++++++++++++--- src/Classes/TradeQueryGenerator.lua | 4 +- 5 files changed, 371 insertions(+), 53 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 129a96d4c5..c6f4b478d5 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -167,7 +167,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" } }, @@ -190,7 +190,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 0ea2e484ed..34f1ae3268 100644 --- a/spec/System/TestTradeQueryRequests_spec.lua +++ b/spec/System/TestTradeQueryRequests_spec.lua @@ -319,14 +319,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 a0d315a75a..9b0ebe10cf 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) @@ -306,6 +420,31 @@ describe("TradeQuery", function() end 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 = { @@ -401,7 +540,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 } } @@ -441,7 +580,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 } @@ -490,7 +629,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) @@ -529,7 +668,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) @@ -653,7 +792,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", @@ -769,7 +908,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" } @@ -790,7 +929,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 9aa40c99e6..5e718080ed 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -61,6 +61,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 @@ -446,7 +454,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 = @@ -677,6 +687,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 @@ -770,7 +781,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() @@ -804,6 +817,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 @@ -952,7 +1090,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 @@ -974,7 +1112,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) @@ -998,7 +1136,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 @@ -1062,7 +1200,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) @@ -1085,7 +1223,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]) @@ -1180,6 +1318,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 @@ -1187,12 +1326,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 @@ -1229,14 +1368,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 @@ -1335,6 +1488,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) ---@param query table A table of filters local function requestQueryHandler(context, query, errMsg) if errMsg then @@ -1349,7 +1503,7 @@ 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) -- Trade URLs contain only the query; the API also needs the sort order. query = dkjson.encode({ query = query, @@ -1358,9 +1512,11 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro }) self.tradeQueryRequests:SearchWithQueryWeightAdjusted(self.pbRealm, self.pbLeague, query, function(items, errMsg, finalQuery) + 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, "") @@ -1390,11 +1546,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 @@ -1451,12 +1609,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 @@ -1465,9 +1626,8 @@ 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) local jewelUniques = { @@ -1477,11 +1637,13 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite 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 requiresJewelSlot = not slotTbl.unique or jewelUniques[slotTbl.slotName] 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 or not requiresJewelSlot) + return isAuthorized and validURL and not isSearching and not isEvaluating + and (hasRequiredJewelSlot or not requiresJewelSlot) end controls["priceButton"..row_idx].tooltipFunc = function(tooltip) tooltip:Clear() @@ -1536,8 +1698,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) @@ -1578,8 +1740,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) @@ -1595,21 +1766,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 @@ -1625,7 +1798,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 @@ -1654,7 +1830,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 fc46dae51c..46bdc4dcd3 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1302,9 +1302,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 e2c805d2d1e178cf823290750b4cee22b19ea021 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 00:54:28 +0200 Subject: [PATCH 08/16] 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 | 156 +++++++++++++++- spec/System/TestTradeQuery_spec.lua | 152 ++++++++++++++-- src/Classes/Item.lua | 131 ++++++++------ src/Classes/TradeQuery.lua | 178 ++++++++++--------- src/Classes/TradeQueryGenerator.lua | 153 ++++++++++++---- 6 files changed, 598 insertions(+), 195 deletions(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index c6d5622d05..ef99bd25bf 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -1418,6 +1418,29 @@ Intangibility: 7% 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 c6f4b478d5..af30b2dad8 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -184,9 +184,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() @@ -194,17 +209,94 @@ 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) describe("Filter prioritization", function() - it("counts socket and link constraints against MAX_FILTERS", function() + it("counts socket and link constraints against the complexity budget", function() local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { items = {} } }) queryGen.modWeights = { } for index = 1, 40 do @@ -252,5 +344,57 @@ describe("TradeQueryGenerator", function() assert.same({ min = 5, max = 5 }, query.filters.socket_filters.filters.links) assert.is_nil(query.filters.socket_filters.filters.sockets) end) + + it("ranks the single empty affix weight with regular filters before applying the complexity budget", 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 9b0ebe10cf..64404398f7 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 = {} }) @@ -539,16 +554,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 @@ -664,7 +679,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 }) @@ -711,6 +726,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 }) @@ -807,13 +844,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 @@ -827,13 +863,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", @@ -841,13 +956,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) @@ -860,7 +974,7 @@ describe("TradeQuery", function() end end return { Life = 100 } - end, nil, staleSnapshot) + end, nil, stalePlan) mock_queryGen.EstimateBenchCraftWeight = originalEstimator assert.are.equal(3, calls) @@ -868,7 +982,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 }, @@ -885,13 +999,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 ff3e17392b..61bbae447d 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,39 @@ 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 + -- Modifiers that grant skills are not affected by modifier magnitude. + local grantsSkill = false + for _, parsedMod in ipairs(mod.modList or { }) do + if parsedMod.name == "ExtraSkill" then + grantsSkill = true + break + end + end + if mod.extra and not grantsSkill and mod.line then + local line = mod.line:lower() + grantsSkill = line:match("^grants level %d+ ") or line:match("^grants %D+$") + end + if grantsSkill 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 @@ -1406,59 +1473,16 @@ 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 - -- Modifiers that grant skills are not affected by modifier magnitude. - local grantsSkill = false - for _, parsedMod in ipairs(mod.modList) do - if parsedMod.name == "ExtraSkill" then - grantsSkill = true - break - end - end - if mod.extra and not grantsSkill then - local line = mod.line:lower() - grantsSkill = line:match("^grants level %d+ ") or line:match("^grants %D+$") - 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 and not grantsSkill 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) if modList then @@ -1467,7 +1491,6 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) mod.extra = extra end end - ::modMagnitudeContinue:: end end end diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 5e718080ed..819f85359b 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -1044,7 +1044,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 @@ -1055,7 +1055,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 @@ -1115,7 +1116,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 @@ -1129,90 +1176,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 @@ -1282,11 +1298,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, @@ -1411,7 +1427,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 @@ -1542,9 +1558,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 46bdc4dcd3..ba09256cb5 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -198,36 +198,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 @@ -236,7 +265,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) @@ -252,8 +281,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) @@ -829,7 +892,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 = { @@ -1000,6 +1062,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 @@ -1085,7 +1150,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+)") @@ -1099,17 +1173,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 complexityBudget = complexityBudget - 2 @@ -1199,13 +1275,22 @@ function TradeQueryGeneratorClass:FinishQuery() end end - for _, entry in ipairs(statFilters) do + 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 -- leave some room for the exact search account name and price query if complexityBudget < 8 then break end complexityBudget = complexityBudget - 4 - t_insert(weightGroup.filters, entry) + t_insert(weightGroup.filters, entry.filter) end local errMsg = nil ConPrintf("filters: %d, budget: %d", #weightGroup.filters, complexityBudget) @@ -1215,9 +1300,7 @@ function TradeQueryGeneratorClass:FinishQuery() end 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, queryTable, errMsg) @@ -1304,7 +1387,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 @@ -1340,7 +1423,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 From bd435b2d785d7dfa405837ef826c3c1304c9644b Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 18:44:33 +0200 Subject: [PATCH 09/16] Compact Trader bench craft tests Reduce duplicated test setup while preserving the full behavior matrix and extending the Trade API fixture through crafted-affix reconstruction and replacement. --- spec/System/TestTradeQueryGenerator_spec.lua | 132 ++-- spec/System/TestTradeQueryRequests_spec.lua | 95 ++- spec/System/TestTradeQuery_spec.lua | 630 +++++++------------ 3 files changed, 336 insertions(+), 521 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index af30b2dad8..737bcd4f11 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -166,38 +166,47 @@ describe("TradeQueryGenerator", function() end) describe("EstimateBenchCraftWeight", function() + local queryGen + + local function makeCraft(spec) + local lines = spec.lines or { spec.line } + spec.line, spec.lines = nil, nil + for _, line in ipairs(lines) do table.insert(spec, line) end + spec.types = spec.types or { Ring = true } + return spec + end + + local function addWeightedTradeMod(spec) + queryGen.modData.Explicit[spec.statOrder .. "_" .. spec.group] = + { tradeMod = { id = spec.id, text = spec.text } } + table.insert(queryGen.modWeights, { tradeModId = spec.id, weight = spec.weight }) + end + + before_each(function() + queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { } }) + queryGen.modData = { Explicit = { } } + queryGen.modWeights = { } + end) + it("adds the weighted values of every craft line", function() - local queryGen = new("TradeQueryGenerator"):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", - } + addWeightedTradeMod({ statOrder = 1203, group = "TestAttributes", id = "explicit.stat_4080418644", + text = "+# to Strength", weight = 2 }) + addWeightedTradeMod({ statOrder = 1204, group = "TestAttributes", id = "explicit.stat_3261801346", + text = "+# to Dexterity", weight = 3 }) + local craft = makeCraft({ lines = { "+(10-10) to Strength", "+(20-20) to Dexterity" }, + statOrder = { 1203, 1204 }, group = "TestAttributes" }) local evaluationPlan = queryGen:CreateBenchCraftEvaluationPlan({ { stat = "Life", weightMult = 1 } }) 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 low = makeCraft({ group = "Test", level = 1 }) + local firstHigh = makeCraft({ group = "Test", level = 2 }) + local secondHigh = makeCraft({ group = "Test", level = 2 }) local crafts = queryGen:GetHighestLevelBenchCrafts({ low, firstHigh, secondHigh }) @@ -205,7 +214,6 @@ describe("TradeQueryGenerator", function() end) it("keeps generated mod and stat weights immutable", function() - local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { } }) queryGen.modWeights = { { tradeModId = "explicit.test", weight = 2 } } local statWeights = { { stat = "Life", weightMult = 1 } } @@ -218,58 +226,25 @@ describe("TradeQueryGenerator", function() 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 prefixLow = makeCraft({ type = "Prefix", level = 1, statOrder = { 1203 }, + group = "TestStrength", line = "+(10-10) to Strength" }) + local prefixHigh = makeCraft({ type = "Prefix", level = 2, statOrder = { 1203 }, + group = "TestStrength", line = "+(20-20) to Strength" }) + local suffix = makeCraft({ type = "Suffix", statOrder = { 1204 }, + group = "TestDexterity", line = "+(10-10) to Dexterity" }) + local incompatible = makeCraft({ type = "Prefix", types = { Amulet = true }, statOrder = { 1203 }, + group = "TestStrength", line = "+(100-100) to Strength" }) + local negative = makeCraft({ type = "Prefix", types = { Belt = true }, statOrder = { 1205 }, + group = "TestIntelligence", line = "+(10-10) to Intelligence" }) + queryGen.itemsTab.build = { + data = { masterMods = { prefixLow, prefixHigh, suffix, incompatible, negative } }, } + addWeightedTradeMod({ statOrder = 1203, group = "TestStrength", id = "explicit.stat_4080418644", + text = "+# to Strength", weight = 2 }) + addWeightedTradeMod({ statOrder = 1204, group = "TestDexterity", id = "explicit.stat_3261801346", + text = "+# to Dexterity", weight = 3 }) + addWeightedTradeMod({ statOrder = 1205, group = "TestIntelligence", id = "explicit.stat_328541901", + text = "+# to Intelligence", weight = -4 }) local evaluationPlan = queryGen:CreateBenchCraftEvaluationPlan({ }) local weight = queryGen:GetBenchCraftQueryWeight({ type = "Ring" }, evaluationPlan) @@ -279,17 +254,12 @@ describe("TradeQueryGenerator", function() end) it("weights only items with exactly one empty affix", function() - local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { } }) - queryGen.GetBenchCraftQueryWeight = function() - return 60 - end + 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.same({ min = 1, max = 1, weight = 60 }, filter.value) assert.are.equal(60, priority) assert.is_nil(filter.priority) end) diff --git a/spec/System/TestTradeQueryRequests_spec.lua b/spec/System/TestTradeQueryRequests_spec.lua index 34f1ae3268..b750ca6fb8 100644 --- a/spec/System/TestTradeQueryRequests_spec.lua +++ b/spec/System/TestTradeQueryRequests_spec.lua @@ -282,53 +282,86 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] assert.are.equal("0", itemsById.empty.weight) end) - it("preserves prefix and suffix metadata from trade modifier tiers", function() + it("reconstructs explicit and crafted affixes for bench craft replacement", function() + local function tradeMod(description, hash, tier, domain) + return { + description = description, domain = domain or "explicit", hash = "stat." .. hash, + mods = { { name = "Test Affix", tier = tier, level = 30 } }, + } + end local response = dkjson.encode({ result = { { id = "affix-metadata", - listing = { - price = { amount = 1, currency = "chaos", type = "~price" }, - whisper = "hi", - account = { name = "seller" }, - }, + listing = { price = { amount = 1, currency = "chaos", type = "~price" }, + whisper = "hi", account = { name = "seller" } }, item = { - rarity = "Rare", - name = "Test Band", - typeLine = "Sapphire Ring", + 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 } } }, + tradeMod("+50 to maximum Life", "explicit.life", "P2"), + tradeMod("20% increased Armour", "explicit.armour", "P2"), + tradeMod("+30% to Fire Resistance", "explicit.fire", "S3"), + tradeMod("+30% to Cold Resistance", "explicit.cold", "S3"), + tradeMod("+20 to Dexterity", "crafted.dexterity", "S3", "crafted"), + tradeMod("10% increased Rarity of Items found", "crafted.rarity", "S3", "crafted"), }, - extended = { hashes = { explicit = { - { "explicit.life", { 0 } }, - { "explicit.armour", { 0 } }, - { "explicit.fire", { 1 } }, - { "explicit.cold", { 2 } }, - } } }, + extended = { hashes = { + explicit = { + { "explicit.life", { 0 } }, { "explicit.armour", { 0 } }, + { "explicit.fire", { 1 } }, { "explicit.cold", { 2 } }, + }, + crafted = { { "crafted.dexterity", { 0 } }, { "crafted.rarity", { 0 } } }, + } }, }, } }, }) local fetchedItems requests.requestQueue.fetch = { } - requests:FetchResultBlock("test", function(items) - fetchedItems = items - end) + requests:FetchResultBlock("test", function(items) fetchedItems = items end) local request = table.remove(requests.requestQueue.fetch, 1) request.callback(response) 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"):TradeQuery({ itemsTab = { } }):GetBenchCraftAvailability(item) - assert.are.equal(2, availability.Prefix) - assert.are.equal(1, availability.Suffix) + local modLines = item.explicitModLines + assert.are.same({ true, true }, { modLines[1].prefix, modLines[2].prefix }) + assert.are.equal(modLines[1].modGroup, modLines[2].modGroup) + assert.are.same({ true, true, true, true }, + { modLines[3].suffix, modLines[4].suffix, modLines[5].suffix, modLines[6].suffix }) + assert.are_not.equal(modLines[3].modGroup, modLines[4].modGroup) + assert.are.same({ true, true }, { modLines[5].crafted, modLines[6].crafted }) + assert.are.equal(modLines[5].modGroup, modLines[6].modGroup) + + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = { } }) + local uncraftedItem = new("Item"):Item(item:BuildRaw()) + for index = #uncraftedItem.explicitModLines, 1, -1 do + if uncraftedItem.explicitModLines[index].crafted then + table.remove(uncraftedItem.explicitModLines, index) + end + end + uncraftedItem = new("Item"):Item(uncraftedItem:BuildRaw()) + assert.are.same({ Prefix = 2, Suffix = 1 }, tradeQuery:GetBenchCraftAvailability(uncraftedItem)) + + local availability, craftState = tradeQuery:GetBenchCraftAvailability(item) + assert.is_nil(availability) + assert.are.same({ count = 1, limit = 1 }, craftState) + + local replacementCraft = { type = "Suffix", group = "Strength", + modTags = { "attribute" }, types = { Ring = true }, "+(21-25) to Strength" } + tradeQuery.tradeQueryGenerator = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { } }) + tradeQuery.itemsTab.build = { data = { masterMods = { replacementCraft } } } + tradeQuery.statSortSelectionList = { { stat = "Life", weightMult = 1 } } + tradeQuery.slotTables[1] = { slotName = "Ring 1", considerBenchCraft = true } + tradeQuery.resultTbl[1] = { fetchedItems[1] } + local evaluation = tradeQuery:GetResultEvaluation(1, 1, function(args) + local raw = args.repItem:BuildRaw() + local hasStrengthCraft = raw:find("{crafted}", 1, true) and raw:find("to Strength", 1, true) + return { Life = hasStrengthCraft and 150 or 100 } + end, { Life = 100 })[1] + + assert.is_truthy(evaluation.benchCraft:find("to Strength", 1, true)) + 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) end) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 64404398f7..c2d1fda2e7 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -6,6 +6,50 @@ describe("TradeQuery", function() mock_tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) end) + + local function newResultQuery(evaluation, itemString) + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = { }, activeItemSet = { }, slots = { } }) + tradeQuery.slotTables[1] = { slotName = "Ring 1" } + if evaluation then + tradeQuery.resultTbl[1] = { { + item_string = itemString or "Rarity: RARE\nBehemoth Hold\nGold Ring", amount = 1, + currency = "chaos", evaluation = next(evaluation) and { evaluation } or nil, + } } + tradeQuery.sortedResultTbl[1] = { { index = 1 } } + end + return tradeQuery + end + + local function buildResultDropdown(tradeQuery) + tradeQuery:PriceItemRowDisplay(1, nil, 0, 20) + return tradeQuery.controls.resultDropdown1 + end + + local function tooltipText(tooltip) + local lines = { } + for _, line in ipairs(tooltip.lines) do + if line.text then + table.insert(lines, line.text) + end + end + return table.concat(lines, "\n") + end + + local function addItemTooltip(_, tooltip, item) + for _, modLine in ipairs(item.explicitModLines or { }) do + local line = modLine.range + and itemLib.applyRange(modLine.line, modLine.range, modLine.valueScalar, modLine.corruptedRange) + or modLine.line + tooltip:AddLine(16, colorCodes.MAGIC .. line, nil, modLine) + end + end + + local function newEvaluationQuery(results) + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = { } }) + tradeQuery.controls.priceButton1, tradeQuery.controls.pbNotice = { label = "Price Item" }, { label = "" } + tradeQuery.resultTbl[1] = results or { { } } + return tradeQuery + end describe("result sorting", function() it("ignores row metadata in Highest Weight order", function() mock_tradeQuery.resultTbl[1] = { { }, { }, { } } @@ -23,10 +67,7 @@ describe("TradeQuery", function() 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 tradeQuery = newEvaluationQuery({ { }, { } }) local events = { } tradeQuery.UpdateControlsWithItems = function(_, _, yieldFunc) table.insert(events, "first") @@ -56,16 +97,14 @@ describe("TradeQuery", function() end) it("clears the prior selection before scheduling a new evaluation", function() - local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + local tradeQuery = newEvaluationQuery() 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" } @@ -81,9 +120,7 @@ describe("TradeQuery", function() 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 tradeQuery = newEvaluationQuery() local evaluated = false tradeQuery.UpdateControlsWithItems = function() evaluated = true @@ -99,8 +136,7 @@ describe("TradeQuery", function() end) it("rejects a response from a superseded fetch", function() - local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) - tradeQuery.controls.priceButton1 = { label = "Price Item" } + local tradeQuery = newEvaluationQuery() local firstFetch = tradeQuery:StartResultFetch(1) local secondFetch = tradeQuery:StartResultFetch(1) @@ -112,10 +148,7 @@ describe("TradeQuery", function() 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 tradeQuery = newEvaluationQuery() local run = 0 local published tradeQuery.UpdateControlsWithItems = function(_, _, yieldFunc) @@ -136,146 +169,60 @@ describe("TradeQuery", function() 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 - -- two itemsTab subtables read by the slot lookup at the top of - -- PriceItemRowDisplay need to be created here; everything else either - -- lives behind a callback we never trigger, or is already initialized - -- by the TradeQuery constructor. - local function newTradeQuery(state) - local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) - tq.itemsTab.activeItemSet = {} - tq.itemsTab.slots = {} - tq.slotTables[1] = { slotName = "Ring 1" } - if state.resultTbl then tq.resultTbl = state.resultTbl end - if state.sortedResultTbl then tq.sortedResultTbl = state.sortedResultTbl end - return tq - end - - -- Builds row 1 of the trader UI and returns the dropdown that owns the - -- tooltipFunc we want to exercise. - local function buildRow1Dropdown(tq) - tq:PriceItemRowDisplay(1, nil, 0, 20) - return tq.controls.resultDropdown1 - end - - it("returns early when sortedResultTbl[row_idx] is missing", function() - -- No sorted results at all -> first guard must short-circuit. - local tq = newTradeQuery({}) - local dropdown = buildRow1Dropdown(tq) - local tooltip = new("Tooltip"):Tooltip() - - assert.has_no.errors(function() - dropdown.tooltipFunc(tooltip, "DROP", 1, nil) - end) - assert.are.equal(0, #tooltip.lines) - end) - - it("returns early when the backing result entry has been cleared", function() - -- The dropdown must be built against a valid result so that - -- PriceItemRowDisplay's construction loop succeeds; we wipe - -- resultTbl[1] only afterwards, to simulate a stale tooltip - -- callback firing after the results were invalidated. - local tq = newTradeQuery({ - resultTbl = { [1] = { [1] = { item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring", amount = 1, currency = "chaos" } } }, - sortedResultTbl = { [1] = { { index = 1 } } }, - }) - local dropdown = buildRow1Dropdown(tq) - tq.resultTbl[1] = {} - local tooltip = new("Tooltip"):Tooltip() - - assert.has_no.errors(function() - dropdown.tooltipFunc(tooltip, "DROP", 1, nil) - 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"):Tooltip() - - dropdown.tooltipFunc(tooltip, "DROP", 1, nil) - - local tooltipText = "" - for _, line in ipairs(tooltip.lines) do - tooltipText = tooltipText .. (line.text or "") .. "\n" + it("returns early when result state is missing or cleared", function() + for _, case in ipairs({ + { name = "sort missing", tradeQuery = newResultQuery() }, + { name = "result cleared", tradeQuery = newResultQuery({ }), clearResult = true }, + }) do + local dropdown = buildResultDropdown(case.tradeQuery) + if case.clearResult then case.tradeQuery.resultTbl[1] = { } end + local tooltip = new("Tooltip"):Tooltip() + + assert.has_no.errors(function() + dropdown.tooltipFunc(tooltip, "DROP", 1, nil) + end, case.name) + assert.are.equal(0, #tooltip.lines, case.name) end - assert.is_truthy(tooltipText:find("Bench craft: +25 to Strength", 1, true)) - 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"):Tooltip() - - dropdown.tooltipFunc(tooltip, "DROP", 1, nil) - - local tooltipText = "" - for _, line in ipairs(tooltip.lines) do - tooltipText = tooltipText .. (line.text or "") .. "\n" + it("describes added and replaced bench crafts in result tooltips", function() + local itemString = "Rarity: RARE\nBehemoth Hold\nGold Ring\nImplicits: 0\n{crafted}{suffix}+25 to Strength" + local cases = { + { name = "added", evaluation = { benchCraft = "+25 to Strength ^8(Suffix)", + benchCraftItemString = itemString }, + expected = { "Bench craft: +25 to Strength", "[Ctrl: compare]" } }, + { name = "replaced", evaluation = { benchCraft = "+25 to Strength ^8(Suffix)", + benchCraftReplaced = "+20 to Dexterity ^8(Suffix)", benchCraftItemString = itemString }, + expected = { "Replace craft: +20 to Dexterity", "-> +25 to Strength" } }, + } + for _, case in ipairs(cases) do + local tq = newResultQuery(case.evaluation) + tq.itemsTab.AddItemTooltip = function() end + local tooltip = new("Tooltip"):Tooltip() + buildResultDropdown(tq).tooltipFunc(tooltip, "DROP", 1, nil) + local text = tooltipText(tooltip) + for _, expected in ipairs(case.expected) do + assert.is_truthy(text:find(expected, 1, true), case.name) + end 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] = { - 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 tq = newResultQuery({ + 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 }, + }, "Rarity: RARE\nBehemoth Hold\nGold Ring\nImplicits: 0\n{prefix}+40 to maximum Mana") + tq.itemsTab.AddItemTooltip = addItemTooltip local previewActive = true tq.IsBenchCraftPreviewActive = function() return previewActive end - local dropdown = buildRow1Dropdown(tq) + local dropdown = buildResultDropdown(tq) local tooltip = new("Tooltip"):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 + local previewText = tooltipText(tooltip.childTooltips[1]) assert.is_truthy(previewText:find("[Craft] +25 to Strength", 1, true)) assert.is_truthy(previewText:find("Estimated with bench craft", 1, true)) @@ -437,17 +384,8 @@ describe("TradeQuery", function() 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) + local tradeQuery = newResultQuery({ }) + buildResultDropdown(tradeQuery) tradeQuery.itemIndexTbl[1] = 2 local tooltip = new("Tooltip"):Tooltip() @@ -515,20 +453,27 @@ describe("TradeQuery", function() 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 makeCraft(spec) + local lines = spec.lines or { spec.line } + spec.line, spec.lines = nil, nil + for _, line in ipairs(lines) do table.insert(spec, line) end + spec.types = spec.types or { Ring = true } + return spec + end + + local prefixCraft = makeCraft({ type = "Prefix", group = "IncreasedLife", + modTags = { "life" }, line = "+(51-55) to maximum Life" }) + local suffixCraft = makeCraft({ type = "Suffix", group = "Strength", + modTags = { "attribute" }, line = "+(21-25) to Strength" }) + local originalEstimator + + before_each(function() + originalEstimator = mock_queryGen.EstimateBenchCraftWeight + end) + + after_each(function() + mock_queryGen.EstimateBenchCraftWeight = originalEstimator + end) local function makeRareRing(prefixCount, suffixCount, extraLines) local lines = { "Rarity: Rare", "Test Ring", "Sapphire Ring", "Implicits: 0" } @@ -542,18 +487,25 @@ describe("TradeQuery", function() "{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 + 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 craftOutput(args, lifeByText) + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted then + for text, life in pairs(lifeByText) do + if modLine.line:find(text, 1, true) then + return { Life = life } + end + end + end + end + return { Life = 100 } + end + local function evaluate(itemString, crafts, calcOverride, yieldFunc, evaluationPlan) local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = { } }) tradeQuery.tradeQueryGenerator = mock_queryGen @@ -564,73 +516,53 @@ describe("TradeQuery", function() { item_string = itemString }, benchCraftEvaluationPlan = evaluationPlan, } - 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 } + local calcFunc = calcOverride or function(args) + return craftOutput(args, { ["maximum Life"] = 300, ["to Strength"] = 150 }) end - return tradeQuery:GetResultEvaluation(1, 1, calcOverride or calc, { Life = 100 }, yieldFunc)[1] + local baseOutput = { Life = 100 } + local evaluation = tradeQuery:GetResultEvaluation(1, 1, calcFunc, baseOutput, yieldFunc)[1] + return evaluation, tradeQuery, calcFunc, baseOutput end - 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) - 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 evaluates prefix crafts when the suffix side is full", function() - local evaluation = evaluate(makeRareRing(2, 3)) + local function assertNoCraft(evaluation, caseName) + assert.are.equal(1, evaluation.weight, caseName) + assert.is_nil(evaluation.benchCraft, caseName) + end - assert.is_true(evaluation.weight > 1) - assert.is_truthy(evaluation.benchCraft:find("maximum Life", 1, true)) + it("evaluates crafts only on the open affix side", function() + local cases = { + { name = "suffix open", prefixes = 3, suffixes = 2, weight = 1.5, craftText = "to Strength" }, + { name = "prefix open", prefixes = 2, suffixes = 3, craftText = "maximum Life" }, + } + for _, case in ipairs(cases) do + local evaluation = evaluate(makeRareRing(case.prefixes, case.suffixes)) + + if case.weight then assert.are.equal(case.weight, evaluation.weight, case.name) + else assert.is_true(evaluation.weight > 1, case.name) end + assert.is_truthy(evaluation.benchCraft:find(case.craftText, 1, true), case.name) + assert.is_truthy(evaluation.benchCraftItemString:find("{crafted}", 1, true), case.name) + assert.are.same({ 6 }, evaluation.benchCraftLineIndexes, case.name) + end end) it("reuses cached craft evaluations when a shared calculator is provided", function() - local tradeQuery = new("TradeQuery"):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) + local _, tradeQuery, calcFunc, baseOutput = evaluate(makeRareRing(3, 2), { suffixCraft }, function(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 } + return craftOutput(args, { ["to Strength"] = 150 }) + end) - tradeQuery:GetResultEvaluation(1, 1, calc, baseOutput) - tradeQuery:GetResultEvaluation(1, 1, calc, baseOutput) + tradeQuery:GetResultEvaluation(1, 1, calcFunc, baseOutput) assert.are.equal(2, calls) 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 amuletCraft = makeCraft({ type = "Prefix", group = "IncreasedLife", modTags = { "life" }, + types = { Amulet = true }, line = "+(51-55) to maximum Life" }) local evaluation = evaluate(makeRareRing(2, 2), { amuletCraft }) - assert.are.equal(1, evaluation.weight) - assert.is_nil(evaluation.benchCraft) + assertNoCraft(evaluation, "Ring cannot use Amulet craft") end) it("previews the same craft roll that was used for evaluation", function() @@ -707,33 +639,29 @@ describe("TradeQuery", function() "{crafted}{suffix}{modGroup:trade:crafted:2}+20% to Cold Resistance", }), { prefixCraft }) - assert.are.equal(1, evaluation.weight) - assert.is_nil(evaluation.benchCraft) + assertNoCraft(evaluation, "crafted modifier limit") end) 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) - assert.is_nil(evaluation.benchCraft) + assertNoCraft(evaluation, marker) 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) + assertNoCraft(evaluation, "existing affix group") end) it("blocks a craft when a differently worded explicit mod occupies its group", function() - local minionCountCraft = { - type = "Prefix", - group = "MaximumMinionCount", + local minionCountCraft = makeCraft({ type = "Prefix", group = "MaximumMinionCount", types = { Helmet = true }, - "+1 to maximum number of Raised Zombies", - "+1 to maximum number of Skeletons", - } + lines = { + "+1 to maximum number of Raised Zombies", + "+1 to maximum number of Skeletons", + } }) local itemString = table.concat({ "Rarity: Rare", "Test Helmet", @@ -744,32 +672,22 @@ describe("TradeQuery", function() 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 }) - - assert.are.equal(1, evaluation.weight) - assert.is_nil(evaluation.benchCraft) + assertNoCraft(evaluation, "same group with different wording") end) - 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 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", - }), { suffixCraft }) - - assert.are.equal(1, evaluation.weight) - assert.is_nil(evaluation.benchCraft) + it("rejects incomplete or contradictory explicit affix metadata", function() + local cases = { + { name = "side absent", lines = { "+50 to maximum Life" } }, + { name = "metadata absent", lines = { } }, + { name = "sides contradictory", lines = { + "{prefix}{modGroup:trade:explicit:0}+50 to maximum Life", + "{suffix}{modGroup:trade:explicit:0}+30% to Fire Resistance", + } }, + } + for _, case in ipairs(cases) do + local evaluation = evaluate(makeRareRing(0, 0, case.lines), { suffixCraft }) + assertNoCraft(evaluation, case.name) + end end) it("counts multi-line trade affixes once", function() @@ -787,13 +705,8 @@ describe("TradeQuery", function() 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", - }) + table.insert(crafts, makeCraft({ type = "Suffix", group = "Candidate" .. index, + modTags = { "attribute" }, line = "+" .. index .. " to Strength" })) end local calls = 0 local maxCraftedLines = 0 @@ -830,24 +743,11 @@ describe("TradeQuery", function() end) it("fully evaluates only the highest estimated-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 evaluationPlan = { - statWeights = { { stat = "Life", weightMult = 1 } }, - } - local originalEstimator = mock_queryGen.EstimateBenchCraftWeight + local predictedBest = makeCraft({ type = "Suffix", group = "PredictedBest", + statOrder = { 1 }, line = "+(1-1) to Strength" }) + local actualBest = makeCraft({ type = "Suffix", group = "ActualBest", + statOrder = { 2 }, line = "+(2-2) to Dexterity" }) + local evaluationPlan = { statWeights = { { stat = "Life", weightMult = 1 } } } mock_queryGen.EstimateBenchCraftWeight = function(_, craft, receivedPlan) assert.are.equal(evaluationPlan, receivedPlan) return craft == predictedBest and 2 or 1 @@ -855,46 +755,24 @@ describe("TradeQuery", function() 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 } + return craftOutput(args, { Dexterity = 300, Strength = 200 }) 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 + local localStunLow = makeCraft({ type = "Prefix", group = "LocalStunDuration", level = 20, + line = "11% increased Stun Duration on Enemies" }) + local localStunHigh = makeCraft({ type = "Prefix", group = "LocalStunDuration", level = 40, + line = "22% increased Stun Duration on Enemies" }) + local localEnergyShield = makeCraft({ type = "Prefix", group = "LocalIncreasedEnergyShield", level = 30, + line = "+10 to maximum Energy Shield" }) + local generalBest = makeCraft({ type = "Prefix", group = "IncreasedLife", level = 30, + line = "+20 to maximum Life" }) + local generalWorse = makeCraft({ type = "Prefix", group = "IncreasedMana", level = 30, + line = "+20 to maximum Mana" }) + local evaluationPlan = { statWeights = { { stat = "Life", weightMult = 1 } } } mock_queryGen.EstimateBenchCraftWeight = function(_, craft) return craft == generalBest and 2 or 1 end @@ -909,8 +787,6 @@ describe("TradeQuery", function() 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"]) @@ -919,64 +795,34 @@ describe("TradeQuery", function() 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 + local attributeCraft = makeCraft({ type = "Suffix", group = "Strength", + modTags = { "attribute" }, line = "+20 to Strength" }) + local lifeCraft = makeCraft({ type = "Suffix", group = "LifeRegeneration", + modTags = { "life" }, line = "Regenerate 20 Life per second" }) + local evaluationPlan = { statWeights = { { stat = "Life", weightMult = 1 } } } 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 } + return craftOutput(args, { Strength = 200 }) 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", - } - local dexterityCraft = { - type = "Suffix", group = "Dexterity", types = { Ring = true }, "+2 to Dexterity", - } - local stalePlan = { - statWeights = { { stat = "Life", weightMult = 2 } }, - } - local originalEstimator = mock_queryGen.EstimateBenchCraftWeight + local strengthCraft = makeCraft({ type = "Suffix", group = "Strength", line = "+1 to Strength" }) + local dexterityCraft = makeCraft({ type = "Suffix", group = "Dexterity", line = "+2 to Dexterity" }) + local stalePlan = { statWeights = { { stat = "Life", weightMult = 2 } } } mock_queryGen.EstimateBenchCraftWeight = function(_, _, receivedPlan) assert.is_nil(receivedPlan) 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 } + return craftOutput(args, { Dexterity = 190, Strength = 150 }) end, nil, stalePlan) - 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)) @@ -984,35 +830,21 @@ describe("TradeQuery", function() it("evaluates only the highest-level craft in a group", function() local crafts = { - { - 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 = "FlaskEffectAndFlaskChargesGained", level = 75, types = { Ring = true }, - "33% reduced Flask Charges gained", "(11-14)% increased Effect of Flasks on you", - }, + makeCraft({ type = "Suffix", group = "FlaskEffectAndFlaskChargesGained", level = 60, + lines = { "20% reduced Flask Charges gained", "(8-10)% increased Effect of Flasks on you" } }), + makeCraft({ type = "Suffix", group = "FlaskEffectAndFlaskChargesGained", level = 75, + lines = { "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 = 150 - end - end - return { Life = life } + return craftOutput(args, { ["20% reduced"] = 200, ["33% reduced"] = 150 }) end) 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() - local multiLineCraft = { - type = "Suffix", group = "FlaskEffectAndFlaskChargesGained", types = { Ring = true }, - "20% reduced Flask Charges gained", "(8-10)% increased Effect of Flasks on you", - } + local multiLineCraft = makeCraft({ type = "Suffix", group = "FlaskEffectAndFlaskChargesGained", + lines = { "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 @@ -1022,37 +854,17 @@ describe("TradeQuery", function() end return { Life = 100 } end) - local tooltipQuery = new("TradeQuery"):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 + local tooltipQuery = newResultQuery(evaluation, originalItemString) + tooltipQuery.itemsTab.AddItemTooltip = addItemTooltip tooltipQuery.IsBenchCraftPreviewActive = function() return true end - tooltipQuery:PriceItemRowDisplay(1, nil, 0, 20) + local dropdown = buildResultDropdown(tooltipQuery) local tooltip = new("Tooltip"):Tooltip() - tooltipQuery.controls.resultDropdown1.tooltipFunc(tooltip, "DROP", 1, nil) + dropdown.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 + local previewText = tooltipText(tooltip.childTooltips[1]) 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) From dd9a2c61bf78ba2e71f5601103787841944532c5 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 19:06:46 +0200 Subject: [PATCH 10/16] Align Trader test fixture terminology Distinguish Trade API records, stripped items, and numeric affix counts from similarly named production shapes. --- spec/System/TestTradeQueryRequests_spec.lua | 26 ++++++++++----------- spec/System/TestTradeQuery_spec.lua | 6 ++--- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/spec/System/TestTradeQueryRequests_spec.lua b/spec/System/TestTradeQueryRequests_spec.lua index b750ca6fb8..f4692054ed 100644 --- a/spec/System/TestTradeQueryRequests_spec.lua +++ b/spec/System/TestTradeQueryRequests_spec.lua @@ -283,7 +283,7 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] end) it("reconstructs explicit and crafted affixes for bench craft replacement", function() - local function tradeMod(description, hash, tier, domain) + local function makeTradeApiMod(description, hash, tier, domain) return { description = description, domain = domain or "explicit", hash = "stat." .. hash, mods = { { name = "Test Affix", tier = tier, level = 30 } }, @@ -297,12 +297,12 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] item = { rarity = "Rare", name = "Test Band", typeLine = "Sapphire Ring", explicitMods = { - tradeMod("+50 to maximum Life", "explicit.life", "P2"), - tradeMod("20% increased Armour", "explicit.armour", "P2"), - tradeMod("+30% to Fire Resistance", "explicit.fire", "S3"), - tradeMod("+30% to Cold Resistance", "explicit.cold", "S3"), - tradeMod("+20 to Dexterity", "crafted.dexterity", "S3", "crafted"), - tradeMod("10% increased Rarity of Items found", "crafted.rarity", "S3", "crafted"), + makeTradeApiMod("+50 to maximum Life", "explicit.life", "P2"), + makeTradeApiMod("20% increased Armour", "explicit.armour", "P2"), + makeTradeApiMod("+30% to Fire Resistance", "explicit.fire", "S3"), + makeTradeApiMod("+30% to Cold Resistance", "explicit.cold", "S3"), + makeTradeApiMod("+20 to Dexterity", "crafted.dexterity", "S3", "crafted"), + makeTradeApiMod("10% increased Rarity of Items found", "crafted.rarity", "S3", "crafted"), }, extended = { hashes = { explicit = { @@ -332,14 +332,14 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] assert.are.equal(modLines[5].modGroup, modLines[6].modGroup) local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = { } }) - local uncraftedItem = new("Item"):Item(item:BuildRaw()) - for index = #uncraftedItem.explicitModLines, 1, -1 do - if uncraftedItem.explicitModLines[index].crafted then - table.remove(uncraftedItem.explicitModLines, index) + local strippedItem = new("Item"):Item(item:BuildRaw()) + for index = #strippedItem.explicitModLines, 1, -1 do + if strippedItem.explicitModLines[index].crafted then + table.remove(strippedItem.explicitModLines, index) end end - uncraftedItem = new("Item"):Item(uncraftedItem:BuildRaw()) - assert.are.same({ Prefix = 2, Suffix = 1 }, tradeQuery:GetBenchCraftAvailability(uncraftedItem)) + strippedItem = new("Item"):Item(strippedItem:BuildRaw()) + assert.are.same({ Prefix = 2, Suffix = 1 }, tradeQuery:GetBenchCraftAvailability(strippedItem)) local availability, craftState = tradeQuery:GetBenchCraftAvailability(item) assert.is_nil(availability) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index c2d1fda2e7..05dd14b9a2 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -531,11 +531,11 @@ describe("TradeQuery", function() it("evaluates crafts only on the open affix side", function() local cases = { - { name = "suffix open", prefixes = 3, suffixes = 2, weight = 1.5, craftText = "to Strength" }, - { name = "prefix open", prefixes = 2, suffixes = 3, craftText = "maximum Life" }, + { name = "suffix open", prefixCount = 3, suffixCount = 2, weight = 1.5, craftText = "to Strength" }, + { name = "prefix open", prefixCount = 2, suffixCount = 3, craftText = "maximum Life" }, } for _, case in ipairs(cases) do - local evaluation = evaluate(makeRareRing(case.prefixes, case.suffixes)) + local evaluation = evaluate(makeRareRing(case.prefixCount, case.suffixCount)) if case.weight then assert.are.equal(case.weight, evaluation.weight, case.name) else assert.is_true(evaluation.weight > 1, case.name) end From 8086ccb338884c06be24b2f7e5e45958dc0806a8 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 29 Aug 2026 12:13:25 +0200 Subject: [PATCH 11/16] Clarify Bench Craft tooltip Describe the user-visible effect without promising a globally optimal craft selection. --- src/Classes/TradeQueryGenerator.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index ba09256cb5..1332600821 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1387,7 +1387,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, - "Searches for one empty affix and ranks each result using its best compatible bench craft or replacement.") + "Considers bench craft potential when searching for and ranking items.") controls.considerBenchCraft.state = self.lastConsiderBenchCraft == true updateLastAnchor(controls.considerBenchCraft) end From 403bf89ddb0581bccc47aee7fa2379d17e4bb329 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 11 Sep 2026 23:52:50 +0200 Subject: [PATCH 12/16] Preserve fetched affixes in bench craft previews --- spec/System/TestTradeQueryRequests_spec.lua | 94 +++++++++++++++++++-- src/Classes/TradeQueryRequests.lua | 8 +- 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/spec/System/TestTradeQueryRequests_spec.lua b/spec/System/TestTradeQueryRequests_spec.lua index f4692054ed..84909f4573 100644 --- a/spec/System/TestTradeQueryRequests_spec.lua +++ b/spec/System/TestTradeQueryRequests_spec.lua @@ -282,7 +282,7 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] assert.are.equal("0", itemsById.empty.weight) end) - it("reconstructs explicit and crafted affixes for bench craft replacement", function() + it("reconstructs explicit, crafted, and fractured affixes for bench craft replacement", function() local function makeTradeApiMod(description, hash, tier, domain) return { description = description, domain = domain or "explicit", hash = "stat." .. hash, @@ -298,6 +298,8 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] rarity = "Rare", name = "Test Band", typeLine = "Sapphire Ring", explicitMods = { makeTradeApiMod("+50 to maximum Life", "explicit.life", "P2"), + { description = "+25 to maximum Mana", domain = "fractured", hash = "stat.fractured.mana", + flags = { fractured = true }, mods = { { name = "Test Affix", tier = "P2", level = 30 } } }, makeTradeApiMod("20% increased Armour", "explicit.armour", "P2"), makeTradeApiMod("+30% to Fire Resistance", "explicit.fire", "S3"), makeTradeApiMod("+30% to Cold Resistance", "explicit.cold", "S3"), @@ -310,6 +312,7 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] { "explicit.fire", { 1 } }, { "explicit.cold", { 2 } }, }, crafted = { { "crafted.dexterity", { 0 } }, { "crafted.rarity", { 0 } } }, + fractured = { { "fractured.mana", { 0 } } }, } }, }, } }, @@ -323,13 +326,16 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] local item = new("Item"):Item(fetchedItems[1].item_string) local modLines = item.explicitModLines - assert.are.same({ true, true }, { modLines[1].prefix, modLines[2].prefix }) - assert.are.equal(modLines[1].modGroup, modLines[2].modGroup) + assert.are.same({ true, true, true }, { modLines[1].prefix, modLines[2].prefix, modLines[3].prefix }) + assert.are_not.equal(modLines[1].modGroup, modLines[2].modGroup) + assert.are.equal("trade:fractured:0", modLines[2].modGroup) + assert.is_true(modLines[2].fractured) + assert.are.equal(modLines[1].modGroup, modLines[3].modGroup) assert.are.same({ true, true, true, true }, - { modLines[3].suffix, modLines[4].suffix, modLines[5].suffix, modLines[6].suffix }) - assert.are_not.equal(modLines[3].modGroup, modLines[4].modGroup) - assert.are.same({ true, true }, { modLines[5].crafted, modLines[6].crafted }) - assert.are.equal(modLines[5].modGroup, modLines[6].modGroup) + { modLines[4].suffix, modLines[5].suffix, modLines[6].suffix, modLines[7].suffix }) + assert.are_not.equal(modLines[4].modGroup, modLines[5].modGroup) + assert.are.same({ true, true }, { modLines[6].crafted, modLines[7].crafted }) + assert.are.equal(modLines[6].modGroup, modLines[7].modGroup) local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = { } }) local strippedItem = new("Item"):Item(item:BuildRaw()) @@ -339,7 +345,7 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] end end strippedItem = new("Item"):Item(strippedItem:BuildRaw()) - assert.are.same({ Prefix = 2, Suffix = 1 }, tradeQuery:GetBenchCraftAvailability(strippedItem)) + assert.are.same({ Prefix = 1, Suffix = 1 }, tradeQuery:GetBenchCraftAvailability(strippedItem)) local availability, craftState = tradeQuery:GetBenchCraftAvailability(item) assert.is_nil(availability) @@ -362,6 +368,78 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] 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)) + local replacementItem = new("Item"):Item(evaluation.benchCraftItemString) + local fracturedManaLine + for _, modLine in ipairs(replacementItem.explicitModLines) do + if modLine.line == "+25 to maximum Mana" then + fracturedManaLine = modLine + break + end + end + assert.is_table(fracturedManaLine) + assert.is_true(fracturedManaLine.fractured) + end) + + it("preserves fetched fractured suffix values in a bench craft preview", function() + local function apiMod(description, hash, tier, domain, flags) + return { description = description, domain = domain or "explicit", hash = "stat." .. hash, + flags = flags, mods = { { name = "Test Affix", tier = tier, level = 30 } } } + end + local response = dkjson.encode({ result = { { + id = "helical-preview", listing = { price = { amount = 1, currency = "chaos", type = "~price" }, whisper = "hi", account = { name = "seller" } }, + item = { rarity = "Rare", name = "Preview Subject", typeLine = "Helical Ring", implicitMods = { + { description = "-2 Prefix Modifiers allowed" }, { description = "+1 Suffix Modifier allowed" }, + { description = "Implicit Modifiers Cannot Be Changed" }, { description = "50% increased Suffix Modifier magnitudes" }, + }, explicitMods = { + apiMod("+50 to maximum Life", "explicit.life", "P2"), + apiMod("+24% to all Elemental Resistances", "explicit.all-res", "S3"), + apiMod("+69% to Lightning Resistance", "explicit.lightning-res", "S3"), + apiMod("+37% to Global Critical Strike Multiplier", "fractured.crit", "S3", "fractured", { fractured = true }), + }, extended = { hashes = { explicit = { { "explicit.life", { 0 } }, { "explicit.all-res", { 1 } }, { "explicit.lightning-res", { 2 } } }, fractured = { { "fractured.crit", { 0 } } } } } }, + } } }) + requests.requestQueue.fetch = { } + local fetchedItems + requests:FetchResultBlock("test", function(items) fetchedItems = items end) + table.remove(requests.requestQueue.fetch, 1).callback(response) + local sourceItem = new("Item"):Item(fetchedItems[1].item_string) + local function findLine(item, text) + for _, modLine in ipairs(item.explicitModLines) do + if modLine.line == text then return modLine end + end + end + local function assertDisplayedValue(item, line, stored, displayed) + local modLine = assert(findLine(item, line)) + assert.are.equal(stored, modLine.line) + assert.is_truthy(itemLib.formatModLine(modLine):find(displayed, 1, true)) + return modLine + end + local sourceCrit = assertDisplayedValue(sourceItem, "+37% to Global Critical Strike Multiplier", "+37% to Global Critical Strike Multiplier", "+37%") + assert.is_true(sourceCrit.fractured) + assertDisplayedValue(sourceItem, "+24% to all Elemental Resistances", "+24% to all Elemental Resistances", "+24%") + assertDisplayedValue(sourceItem, "+69% to Lightning Resistance", "+69% to Lightning Resistance", "+69%") + + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = { } }) + local strengthCraft = { type = "Suffix", group = "Strength", modTags = { "attribute" }, types = { Ring = true }, "+(21-25) to Strength" } + tradeQuery.tradeQueryGenerator = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { } }) + tradeQuery.itemsTab.build = { data = { masterMods = { strengthCraft } } } + tradeQuery.statSortSelectionList = { { stat = "Life", weightMult = 1 } } + tradeQuery.slotTables[1] = { slotName = "Ring 1", considerBenchCraft = true } + tradeQuery.resultTbl[1] = { fetchedItems[1] } + local previousAffixQuality = main.defaultItemAffixQuality + main.defaultItemAffixQuality = 0.5 + local evaluation = tradeQuery:GetResultEvaluation(1, 1, function(args) + return { Life = args.repItem:BuildRaw():find("to Strength", 1, true) and 150 or 100 } + end, { Life = 100 })[1] + main.defaultItemAffixQuality = previousAffixQuality + assert.is_truthy(evaluation.benchCraftItemString) + local previewItem = new("Item"):Item(evaluation.benchCraftItemString) + local previewCrit = assertDisplayedValue(previewItem, "+37% to Global Critical Strike Multiplier", "+37% to Global Critical Strike Multiplier", "+37%") + assert.is_true(previewCrit.fractured) + assertDisplayedValue(previewItem, "+24% to all Elemental Resistances", "+24% to all Elemental Resistances", "+24%") + assertDisplayedValue(previewItem, "+69% to Lightning Resistance", "+69% to Lightning Resistance", "+69%") + local previewStrength = assert(findLine(previewItem, "+(21-25) to Strength")) + assert.is_true(previewStrength.crafted) + assert.is_truthy(itemLib.formatModLine(previewStrength):find("+34 to Strength", 1, true)) end) end) diff --git a/src/Classes/TradeQueryRequests.lua b/src/Classes/TradeQueryRequests.lua index 9aa166910f..c9df101840 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -360,7 +360,7 @@ function TradeQueryRequestsClass:FetchResultBlock(url, callback) end local groupsByDomain = { } - for _, domain in ipairs({ "explicit", "crafted" }) do + for _, domain in ipairs({ "explicit", "crafted", "fractured" }) 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 @@ -412,6 +412,12 @@ function TradeQueryRequestsClass:FetchResultBlock(url, callback) local function processLine(modLine, includeAffixMetadata) local s = "" + -- Trade API descriptions already contain their live resolved values. A later + -- bench-craft preview makes the reconstructed item an advanced copy; retain + -- these fetched values instead of applying local magnitude modifiers again. + if includeAffixMetadata and not (modLine.flags and modLine.flags.unscalable) then + s = s .. "{unscalable}" + end for flagName, flag in pairs(modLine.flags or {}) do if flag then s = s .. string.format("{%s}", flagName) From cae6ffd27f200ecccf93955cf086aef6b7423a7e Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 12 Sep 2026 00:07:21 +0200 Subject: [PATCH 13/16] Keep Trade modifier order in bench craft previews --- spec/System/TestTradeQuery_spec.lua | 45 +++++++++++++++++++++++ src/Classes/TradeQuery.lua | 56 +++++++++++++++++++++++++---- 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 05dd14b9a2..c8a34c8871 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -235,6 +235,51 @@ describe("TradeQuery", function() dropdown.tooltipFunc(tooltip, "DROP", 1, nil) assert.is_nil(tooltip.childTooltips) end) + + it("preserves Trade line occurrences and craft highlighting in Ctrl previews", function() + local header = "Rarity: RARE\nBehemoth Hold\nGold Ring\nImplicits: 0\n" + local prefix = "{prefix}+40 to maximum Mana" + local fractured = "{fractured}{suffix}+37% to Global Critical Strike Multiplier" + local ordinaryCrit = "{suffix}+37% to Global Critical Strike Multiplier" + local oldCraft = "{crafted}{suffix}+20 to Dexterity" + local strength = "{range:0.5}{crafted}{suffix}+(21-25) to Strength" + local intelligence = "{range:0.5}{crafted}{suffix}+(21-25) to Intelligence" + local cases = { + { name = "addition after trailing fracture", source = header .. prefix .. "\n" .. fractured, + preview = header .. prefix .. "\n" .. fractured .. "\n" .. strength, indexes = { 3 }, expected = { + { "+40 to maximum Mana", false, false, false }, { "+37% to Global Critical Strike Multiplier", true, false, false }, { "+23 to Strength", false, true, true }, + } }, + { name = "middle replacement expands to two lines", source = header .. prefix .. "\n" .. oldCraft .. "\n" .. fractured, + preview = header .. prefix .. "\n" .. fractured .. "\n" .. strength .. "\n" .. intelligence, indexes = { 3, 4 }, replaced = "+20 to Dexterity ^8(Suffix)", expected = { + { "+40 to maximum Mana", false, false, false }, { "+23 to Strength", false, true, true }, { "+23 to Intelligence", false, true, true }, { "+37% to Global Critical Strike Multiplier", true, false, false }, + } }, + { name = "ordinary and fractured duplicate text", source = header .. prefix .. "\n" .. ordinaryCrit .. "\n" .. fractured, + preview = header .. prefix .. "\n" .. ordinaryCrit .. "\n" .. fractured .. "\n" .. strength, indexes = { 4 }, expected = { + { "+40 to maximum Mana", false, false, false }, { "+37% to Global Critical Strike Multiplier", false, false, false }, { "+37% to Global Critical Strike Multiplier", true, false, false }, { "+23 to Strength", false, true, true }, + } }, + { name = "addition preserves existing craft", source = header .. prefix .. "\n" .. oldCraft .. "\n" .. fractured, + preview = header .. prefix .. "\n" .. oldCraft .. "\n" .. fractured .. "\n" .. strength, indexes = { 4 }, expected = { + { "+40 to maximum Mana", false, false, false }, { "+20 to Dexterity", false, true, false }, { "+37% to Global Critical Strike Multiplier", true, false, false }, { "+23 to Strength", false, true, true }, + } }, + } + for _, case in ipairs(cases) do + local tq = newResultQuery({ benchCraft = "+(21-25) to Strength ^8(Suffix)", benchCraftReplaced = case.replaced, + benchCraftItemString = case.preview, benchCraftLineIndexes = case.indexes }, case.source) + tq.itemsTab.AddItemTooltip = addItemTooltip + tq.IsBenchCraftPreviewActive = function() return true end + local tooltip = new("Tooltip"):Tooltip() + buildResultDropdown(tq).tooltipFunc(tooltip, "DROP", 1, nil) + local actual = { } + for _, line in ipairs(tooltip.childTooltips[1].lines) do + if line.modLine then + local text = StripEscapes(line.text) + local crafted = text:find("^%[Craft%] ") ~= nil + actual[#actual + 1] = { text:gsub("^%[Craft%] ", ""), line.modLine.fractured == true, line.modLine.crafted == true, crafted } + end + end + assert.are.same(case.expected, actual, case.name) + end + end) end) describe("trade search links", function() local dkjson = require "dkjson" diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 819f85359b..4c526aa46c 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -1710,15 +1710,11 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite tooltip:AddLine(16, craftLabel .. evaluation.benchCraft .. compareHint) return evaluation end - local function addBenchCraftPreviewIfApplicable(tooltip, evaluation, tooltipSlot) + local function addBenchCraftPreviewIfApplicable(tooltip, evaluation, tooltipSlot, sourceItem) if not evaluation or not evaluation.benchCraftItemString or not self:IsBenchCraftPreviewActive() then return end 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) local craftedModLines = { } for _, lineIndex in ipairs(evaluation.benchCraftLineIndexes or { }) do local modLine = previewItem.explicitModLines[lineIndex] @@ -1726,6 +1722,54 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite craftedModLines[modLine] = true end end + local function lineKey(modLine) + return table.concat({ modLine.line or "", tostring(modLine.prefix), tostring(modLine.suffix), + tostring(modLine.fractured), tostring(modLine.crafted), modLine.modGroup or "" }, "\0") + end + local remaining = { } + for _, modLine in ipairs(previewItem.explicitModLines) do + t_insert(remaining, modLine) + end + local ordered = { } + local insertedReplacement = false + for _, sourceModLine in ipairs(sourceItem.explicitModLines) do + local sourceKey = lineKey(sourceModLine) + local matchedIndex + for index, previewModLine in ipairs(remaining) do + if not craftedModLines[previewModLine] and lineKey(previewModLine) == sourceKey then + matchedIndex = index + break + end + end + if matchedIndex then + local previewModLine = t_remove(remaining, matchedIndex) + t_insert(ordered, previewModLine) + elseif sourceModLine.crafted and evaluation.benchCraftReplaced and not insertedReplacement then + local replacementCraftLines = { } + for _, previewModLine in ipairs(remaining) do + if craftedModLines[previewModLine] then + t_insert(replacementCraftLines, previewModLine) + end + end + for index = #remaining, 1, -1 do + if craftedModLines[remaining[index]] then + t_remove(remaining, index) + end + end + for _, previewModLine in ipairs(replacementCraftLines) do + t_insert(ordered, previewModLine) + end + insertedReplacement = true + end + end + for _, previewModLine in ipairs(remaining) do + t_insert(ordered, previewModLine) + end + previewItem.explicitModLines = ordered + local previewTooltip = tooltip.benchCraftPreviewTooltip or new("Tooltip"):Tooltip() + tooltip.benchCraftPreviewTooltip = previewTooltip + previewTooltip:Clear() + self.itemsTab:AddItemTooltip(previewTooltip, previewItem, tooltipSlot) 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) @@ -1752,7 +1796,7 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite self.itemsTab:AddItemTooltip(tooltip, item, tooltipSlot) addMegalomaniacCompareToTooltipIfApplicable(tooltip, pb_index) local benchCraftEvaluation = addBenchCraftToTooltipIfApplicable(tooltip, result) - addBenchCraftPreviewIfApplicable(tooltip, benchCraftEvaluation, tooltipSlot) + addBenchCraftPreviewIfApplicable(tooltip, benchCraftEvaluation, tooltipSlot, item) tooltip:AddSeparator(10) tooltip:AddLine(16, string.format("^7Price: %s %s", result.amount, result.currency)) end From 49aebf23fd33682938311ebe452cca7e950e4bc0 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 27 Sep 2026 11:00:53 +0200 Subject: [PATCH 14/16] Remove a redundant bench craft tie test Equal-level crafts have no defined order. Existing tests already cover choosing the highest craft level in a group. --- spec/System/TestTradeQueryGenerator_spec.lua | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 737bcd4f11..01ab635079 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -203,16 +203,6 @@ describe("TradeQueryGenerator", function() assert.are.equal(80, evaluationPlan.craftWeights[craft]) end) - it("keeps the first craft when the highest levels in a group are tied", function() - local low = makeCraft({ group = "Test", level = 1 }) - local firstHigh = makeCraft({ group = "Test", level = 2 }) - local secondHigh = makeCraft({ 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() queryGen.modWeights = { { tradeModId = "explicit.test", weight = 2 } } local statWeights = { { stat = "Life", weightMult = 1 } } From 59de078ab6bb70003ed11c666fdc79b9d304571a Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 27 Sep 2026 12:01:09 +0200 Subject: [PATCH 15/16] Test trade query behavior without exact UI wording Keep the checks for progress, cleanup, price removal, mod changes, and the Ctrl preview. --- spec/System/TestTradeQuery_spec.lua | 41 +++++++++++++++++++---------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index c8a34c8871..231954cadb 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -46,7 +46,7 @@ describe("TradeQuery", function() local function newEvaluationQuery(results) local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = { } }) - tradeQuery.controls.priceButton1, tradeQuery.controls.pbNotice = { label = "Price Item" }, { label = "" } + tradeQuery.controls.priceButton1, tradeQuery.controls.pbNotice = { }, { label = "" } tradeQuery.resultTbl[1] = results or { { } } return tradeQuery end @@ -76,23 +76,27 @@ describe("TradeQuery", function() yieldFunc(2, 2) table.insert(events, "done") end + tradeQuery:CancelResultEvaluation(1) + local idleLabel = tradeQuery.controls.priceButton1.label tradeQuery:StartResultEvaluation(1) assert.are.same({ }, events) - assert.are.equal("Eval 0/2...", tradeQuery.controls.priceButton1.label) + local progressLabel = tradeQuery.controls.priceButton1.label + assert.are_not.equal(idleLabel, progressLabel) tradeQuery:ProcessResultEvaluations() assert.are.same({ "first" }, events) - assert.are.equal("Eval 1/2...", tradeQuery.controls.priceButton1.label) + assert.are_not.equal(progressLabel, tradeQuery.controls.priceButton1.label) + progressLabel = tradeQuery.controls.priceButton1.label tradeQuery:ProcessResultEvaluations() assert.are.same({ "first", "second" }, events) - assert.are.equal("Eval 2/2...", tradeQuery.controls.priceButton1.label) + assert.are_not.equal(progressLabel, tradeQuery.controls.priceButton1.label) tradeQuery:ProcessResultEvaluations() assert.are.same({ "first", "second", "done" }, events) - assert.are.equal("Price Item", tradeQuery.controls.priceButton1.label) + assert.are.equal(idleLabel, tradeQuery.controls.priceButton1.label) assert.is_nil(tradeQuery.resultEvaluationContexts[1]) end) @@ -104,7 +108,7 @@ describe("TradeQuery", function() dropdownList = list end, } - tradeQuery.controls.fullPrice = { label = "" } + tradeQuery.controls.fullPrice = { label = "^7Total Price: 1 chaos" } tradeQuery.sortedResultTbl[1] = { { index = 1 } } tradeQuery.itemIndexTbl[1] = 1 tradeQuery.totalPrice[1] = { amount = 1, currency = "chaos" } @@ -116,7 +120,7 @@ describe("TradeQuery", function() 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) + assert.is_nil(tradeQuery.controls.fullPrice.label:find("1 chaos", 1, true)) end) it("does not replace an active fetch with evaluation of old results", function() @@ -125,26 +129,35 @@ describe("TradeQuery", function() tradeQuery.UpdateControlsWithItems = function() evaluated = true end + tradeQuery:CancelResultEvaluation(1) + local idleLabel = tradeQuery.controls.priceButton1.label local fetchContext = tradeQuery:StartResultFetch(1) + local activeLabel = tradeQuery.controls.priceButton1.label tradeQuery:StartResultEvaluation(1) + assert.are_not.equal(idleLabel, activeLabel) 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) + assert.are.equal(activeLabel, tradeQuery.controls.priceButton1.label) end) it("rejects a response from a superseded fetch", function() local tradeQuery = newEvaluationQuery() + tradeQuery:CancelResultEvaluation(1) + local idleLabel = tradeQuery.controls.priceButton1.label local firstFetch = tradeQuery:StartResultFetch(1) + local activeLabel = tradeQuery.controls.priceButton1.label local secondFetch = tradeQuery:StartResultFetch(1) assert.is_false(tradeQuery:FinishResultFetch(1, firstFetch)) - assert.are.equal("Searching...", tradeQuery.controls.priceButton1.label) + assert.are_not.equal(idleLabel, tradeQuery.controls.priceButton1.label) + assert.are.equal(activeLabel, tradeQuery.controls.priceButton1.label) assert.is_true(tradeQuery:FinishResultFetch(1, secondFetch)) - assert.are.equal("Price Item", tradeQuery.controls.priceButton1.label) + assert.is_nil(tradeQuery.resultFetchContexts[1]) + assert.are.equal(idleLabel, tradeQuery.controls.priceButton1.label) end) it("publishes only the replacement of a suspended evaluation", function() @@ -190,10 +203,10 @@ describe("TradeQuery", function() local cases = { { name = "added", evaluation = { benchCraft = "+25 to Strength ^8(Suffix)", benchCraftItemString = itemString }, - expected = { "Bench craft: +25 to Strength", "[Ctrl: compare]" } }, + expected = { "+25 to Strength", "[Ctrl: compare]" } }, { name = "replaced", evaluation = { benchCraft = "+25 to Strength ^8(Suffix)", benchCraftReplaced = "+20 to Dexterity ^8(Suffix)", benchCraftItemString = itemString }, - expected = { "Replace craft: +20 to Dexterity", "-> +25 to Strength" } }, + expected = { "+20 to Dexterity", "+25 to Strength" } }, } for _, case in ipairs(cases) do local tq = newResultQuery(case.evaluation) @@ -207,7 +220,7 @@ describe("TradeQuery", function() end end) - it("shows the simulated item and highlights its craft while Ctrl is held", function() + it("shows the simulated item as an estimate and highlights its craft while Ctrl is held", function() local tq = newResultQuery({ 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", @@ -224,7 +237,7 @@ describe("TradeQuery", function() assert.are.equal(1, #tooltip.childTooltips) local previewText = tooltipText(tooltip.childTooltips[1]) assert.is_truthy(previewText:find("[Craft] +25 to Strength", 1, true)) - assert.is_truthy(previewText:find("Estimated with bench craft", 1, true)) + assert.is_truthy(previewText:find("Estimated", 1, true)) previewActive = false dropdown.tooltipFunc(tooltip, "DROP", 1, nil) From de4098372dfde92ebdc901e74621f3dce3b27665 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Thu, 29 Oct 2026 12:03:38 +0100 Subject: [PATCH 16/16] Align bench craft tests with the current query lifecycle Read the generated filter table directly and advance cooperative result evaluation before checking the completed search state. --- spec/System/TestTradeQueryGenerator_spec.lua | 4 ++-- spec/System/TestTradeQuery_spec.lua | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 01ab635079..46c8a49b1a 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -341,8 +341,8 @@ describe("TradeQueryGenerator", function() }, 35.5 end local query - queryGen.requesterCallback = function(_, queryJson) - query = require("dkjson").decode(queryJson).query + queryGen.requesterCallback = function(_, generatedQuery) + query = generatedQuery end queryGen:FinishQuery() diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 231954cadb..b373e47960 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -353,6 +353,7 @@ describe("TradeQuery", function() finalSearch.callback([[{"id":"final","total":10,"result":["item1"]}]]) assert.are.equal(finalSearch.body, mock_tradeQuery.lastQueries[1]) assert.are.equal("https://www.pathofexile.com/trade/search/sony/Hardcore%20Test/final", mock_tradeQuery.controls.uri1.buf) + mock_tradeQuery:ProcessResultEvaluations() assert.are.equal("Price Item", mock_tradeQuery.controls.priceButton1.label) end)