From a96cc57020b505b278fd857f2de9cfbb78bb4db4 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 14:46:33 +0200 Subject: [PATCH 01/10] 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 f05068d8fa..816de3b757 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("FetchResults", function() diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index c700a332cf..ffbd94ddcb 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("GetResultEvaluation", function() it("uses the first visible ring for a Pearl result without a selected slot", function() @@ -176,4 +247,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 66ad1fc1ff..4f5819bc28 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -803,6 +803,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 = {} @@ -817,6 +821,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] @@ -874,7 +1068,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 @@ -1227,6 +1431,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 @@ -1239,9 +1478,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 826cb34a08..b40d608e6a 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1239,6 +1239,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) @@ -1380,6 +1390,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 709d0c51bb..85bba4fec5 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -348,13 +348,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)) @@ -368,7 +428,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 0ff0e90c28bdd437162922c78b7a2bbf4c935354 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 16:11:32 +0200 Subject: [PATCH 02/10] 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 ffbd94ddcb..6381a43175 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -309,7 +309,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) @@ -318,19 +318,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 @@ -340,7 +340,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) @@ -369,7 +369,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) @@ -384,21 +384,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", @@ -453,11 +453,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", }, } @@ -478,7 +478,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 b40d608e6a..d0dec3d2f5 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1244,7 +1244,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 2cea89c1b1ff806b53c1ded22f21474623b33906 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 17:56:19 +0200 Subject: [PATCH 03/10] 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 6381a43175..005ef983c1 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -350,7 +350,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 }) @@ -358,7 +358,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 09a3aebd03b666be1ec197a26c482efcc8626840 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 18:09:28 +0200 Subject: [PATCH 04/10] 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 005ef983c1..4c76ee719a 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -325,6 +325,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 28b393308d83af2dc538b11eab5cea3ff2110662 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 18:51:57 +0200 Subject: [PATCH 05/10] 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 4c76ee719a..2a3f632a92 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] = { @@ -358,10 +386,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() @@ -370,6 +431,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 4f5819bc28..fb4376cdcc 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -892,13 +892,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) @@ -946,22 +950,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 @@ -994,6 +1034,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) @@ -1008,7 +1049,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 @@ -1068,9 +1109,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, @@ -1078,6 +1119,7 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba benchCraft = benchCraft, benchCraftItemString = benchCraftItemString, benchCraftLineIndexes = benchCraftLineIndexes, + benchCraftReplaced = benchCraftReplaced, }} end return result.evaluation @@ -1437,8 +1479,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 d0dec3d2f5..c8bdf8c507 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1243,8 +1243,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 baa0d7e340a52d3adc447a6b5bd1e8be35a76f6e Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 10 Aug 2026 01:12:44 +0200 Subject: [PATCH 06/10] 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 ff8b269fc0..7ed333afa1 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 2a3f632a92..6dba2817bf 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -316,13 +316,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 @@ -334,7 +337,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() @@ -353,6 +356,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", @@ -526,6 +554,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 fb4376cdcc..daf0d6f52c 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -976,7 +976,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 @@ -999,10 +999,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 @@ -1028,6 +1045,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 @@ -1047,30 +1067,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 @@ -1078,10 +1100,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 @@ -1108,10 +1137,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, @@ -1342,6 +1378,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 c8bdf8c507..baabca1fdd 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 = { @@ -1159,6 +1218,11 @@ function TradeQueryGeneratorClass:FinishQuery() end local queryJson = dkjson.encode(queryTable) + if self.requesterContext then + self.requesterContext.benchCraftWeightSnapshot = self.requesterContext.slotTbl + and self.requesterContext.slotTbl.considerBenchCraft + and self:CreateBenchCraftWeightSnapshot(self.calcContext.options.statWeights) or nil + end self.requesterCallback(self.requesterContext, queryJson, errMsg) -- Close blocker popup @@ -1244,7 +1308,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 18ec49662a868b11e689f05a3efe8dee9ab655b0 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Wed, 12 Aug 2026 00:38:37 +0200 Subject: [PATCH 07/10] 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 7ed333afa1..daa3904de0 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 816de3b757..fc1510fd9b 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 6dba2817bf..b896cf033e 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) @@ -222,6 +336,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 = { @@ -317,7 +456,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 } } @@ -357,7 +496,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 } @@ -406,7 +545,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) @@ -445,7 +584,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) @@ -569,7 +708,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", @@ -685,7 +824,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" } @@ -706,7 +845,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 daf0d6f52c..2358ca5a48 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -60,6 +60,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 @@ -445,7 +453,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 = @@ -676,6 +686,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 @@ -769,7 +780,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() @@ -803,6 +816,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 @@ -951,7 +1089,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 @@ -973,7 +1111,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) @@ -997,7 +1135,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 @@ -1061,7 +1199,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) @@ -1084,7 +1222,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]) @@ -1179,6 +1317,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 @@ -1186,12 +1325,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 @@ -1228,14 +1367,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 @@ -1334,6 +1487,7 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro local nameColor = slotTbl.unique and colorCodes.UNIQUE or "^7" controls["name" .. row_idx] = new("LabelControl"):LabelControl(top_pane_alignment_ref, { 0, row_idx * (row_height + row_vertical_padding), 135, row_height - 4 }, nameColor .. slotTbl.slotName) controls["bestButton" .. row_idx] = new("ButtonControl"):ButtonControl({ "LEFT", controls["name" .. row_idx], "LEFT" }, { 135 + 8, 0, 80, row_height }, "Find best", function() + self:CancelResultFetch(row_idx) self.tradeQueryGenerator:RequestQuery(activeSlot, { slotTbl = slotTbl, controls = controls, row_idx = row_idx }, self.statSortSelectionList, function(context, query, errMsg) if errMsg then self:SetNotice(context.controls.pbNotice, colorCodes.NEGATIVE .. errMsg) @@ -1347,13 +1501,15 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro controls["uri"..context.row_idx]:SetText(url, true) return end - context.controls["priceButton"..context.row_idx].label = "Searching..." + local fetchContext = self:StartResultFetch(context.row_idx) self.lastQueries[row_idx] = query self.tradeQueryRequests:SearchWithQueryWeightAdjusted(self.pbRealm, self.pbLeague, query, function(items, errMsg) + if not self:FinishResultFetch(context.row_idx, fetchContext) then + return + end if errMsg then self:SetNotice(context.controls.pbNotice, colorCodes.NEGATIVE .. errMsg) - context.controls["priceButton"..context.row_idx].label = "Price Item" return else self:SetNotice(context.controls.pbNotice, "") @@ -1382,11 +1538,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 @@ -1442,12 +1600,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 @@ -1456,9 +1617,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 = { @@ -1468,11 +1628,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() @@ -1527,8 +1689,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) @@ -1569,8 +1731,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) @@ -1586,21 +1757,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 @@ -1616,7 +1789,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 @@ -1644,7 +1820,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 baabca1fdd..d4787ab765 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1306,9 +1306,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 faf0e83b5a8676bb2e1dbad193f09e4582eb747c Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 00:54:28 +0200 Subject: [PATCH 08/10] 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 66bc1cdb39..6f926ef5ef 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -1306,6 +1306,29 @@ describe("TestAdvancedItemParse #item", function() assert.are.equals(195, chaosDamageInc()) end) + it("calculates catalyst and magnitude scaling for a proposed explicit mod", function() + local item = new("Item"):Item([[ + Rarity: RARE + Test Subject + Sapphire Ring + Catalyst: Intrinsic + CatalystQuality: 20 + Implicits: 0 + {range:0.5}50% increased effect of prefixes + ]]) + item.modMagnitudeMods = { + { tags = { "prefix" }, multiplier = 2 }, + { tags = { "prefix" }, quality = 50 }, + } + local attributePrefix = { modTags = { "attribute" }, prefix = true } + local attributeSuffix = { modTags = { "attribute" }, suffix = true } + + assert.are.equals(2.9, item:GetModLineValueScalar(attributePrefix, "explicit")) + assert.are.equals(1.2, item:GetModLineValueScalar(attributeSuffix, "explicit")) + attributePrefix.unscalable = true + assert.are.equals(1, item:GetModLineValueScalar(attributePrefix, "explicit")) + end) + -- actually a ring so we don't have to allocate a socket local realJewel = [[ Rarity: Rare diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index daa3904de0..b2c811556f 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 @@ -241,5 +333,57 @@ describe("TradeQueryGenerator", function() assert.is_not_nil(query.filters.socket_filters.filters.sockets) assert.is_not_nil(query.filters.socket_filters.filters.links) end) + + it("ranks the single empty affix weight with regular filters before applying 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 b896cf033e..29685fdbed 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 = {} }) @@ -455,16 +470,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 @@ -580,7 +595,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 }) @@ -627,6 +642,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 }) @@ -723,13 +760,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 @@ -743,13 +779,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", @@ -757,13 +872,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) @@ -776,7 +890,7 @@ describe("TradeQuery", function() end end return { Life = 100 } - end, nil, staleSnapshot) + end, nil, stalePlan) mock_queryGen.EstimateBenchCraftWeight = originalEstimator assert.are.equal(3, calls) @@ -784,7 +898,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 }, @@ -801,13 +915,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 1618e93e01..51b9b772fb 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 @@ -1401,59 +1468,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 @@ -1462,7 +1486,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 2358ca5a48..19427ac8c3 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -1043,7 +1043,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 @@ -1054,7 +1054,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 @@ -1114,7 +1115,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 @@ -1128,90 +1175,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 @@ -1281,11 +1297,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, @@ -1410,7 +1426,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 @@ -1534,9 +1550,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 d4787ab765..40df733a86 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 @@ -1089,7 +1154,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+)") @@ -1103,17 +1177,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 @@ -1202,13 +1278,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) @@ -1219,9 +1304,7 @@ function TradeQueryGeneratorClass:FinishQuery() local queryJson = dkjson.encode(queryTable) if self.requesterContext then - self.requesterContext.benchCraftWeightSnapshot = self.requesterContext.slotTbl - and self.requesterContext.slotTbl.considerBenchCraft - and self:CreateBenchCraftWeightSnapshot(self.calcContext.options.statWeights) or nil + self.requesterContext.benchCraftEvaluationPlan = benchCraftEvaluationPlan end self.requesterCallback(self.requesterContext, queryJson, errMsg) @@ -1308,7 +1391,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 @@ -1344,7 +1427,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 1834915c1d3d4bdbaf0d81dd29df9bb74995c1c8 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 18:44:33 +0200 Subject: [PATCH 09/10] 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 | 632 +++++++------------ 3 files changed, 337 insertions(+), 522 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index b2c811556f..c1f5962648 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 fc1510fd9b..bab7c9efb0 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 29685fdbed..d417c1db94 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) + end) - 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)) @@ -353,17 +300,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() @@ -431,20 +369,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" } @@ -458,18 +403,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 @@ -480,73 +432,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() @@ -623,33 +555,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", @@ -660,32 +588,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) - 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) + assertNoCraft(evaluation, "same group with different wording") 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() @@ -703,13 +621,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 @@ -746,24 +659,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 @@ -771,46 +671,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 @@ -825,8 +703,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"]) @@ -835,64 +711,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)) @@ -900,35 +746,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 @@ -938,37 +770,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 6afb95bf104f968c670cf8367ac76d80d198cde0 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 19:06:46 +0200 Subject: [PATCH 10/10] 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 bab7c9efb0..1b9d3a4ce5 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 d417c1db94..666af81ee2 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -447,11 +447,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