From 8726d221ddb273e6978c9e281970d587b5642083 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 14:46:33 +0200 Subject: [PATCH 1/5] 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 f8e53ea5e7b..f31e1bc03de 100644 --- a/spec/System/TestTradeQueryRequests_spec.lua +++ b/spec/System/TestTradeQueryRequests_spec.lua @@ -238,6 +238,55 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] assert.are.equal("42", itemsById.legacy.weight) assert.are.equal("0", itemsById.empty.weight) end) + + it("preserves prefix and suffix metadata from trade modifier tiers", function() + local response = dkjson.encode({ + result = { { + id = "affix-metadata", + listing = { + price = { amount = 1, currency = "chaos", type = "~price" }, + whisper = "hi", + account = { name = "seller" }, + }, + item = { + rarity = "Rare", + name = "Test Band", + typeLine = "Sapphire Ring", + explicitMods = { + { description = "+50 to maximum Life", domain = "explicit", hash = "stat.explicit.life", mods = { { name = "Sanguine", tier = "P2", level = 50 } } }, + { description = "20% increased Armour", domain = "explicit", hash = "stat.explicit.armour", mods = { { name = "Sanguine", tier = "P2", level = 50 } } }, + { description = "+30% to Fire Resistance", domain = "explicit", hash = "stat.explicit.fire", mods = { { name = "of Craft", tier = "S3", level = 30 } } }, + { description = "+30% to Cold Resistance", domain = "explicit", hash = "stat.explicit.cold", mods = { { name = "of Craft", tier = "S3", level = 30 } } }, + }, + extended = { hashes = { explicit = { + { "explicit.life", { 0 } }, + { "explicit.armour", { 0 } }, + { "explicit.fire", { 1 } }, + { "explicit.cold", { 2 } }, + } } }, + }, + } }, + }) + local fetchedItems + requests.requestQueue.fetch = { } + requests:FetchResultBlock("test", function(items) + fetchedItems = items + end) + + local request = table.remove(requests.requestQueue.fetch, 1) + request.callback(response) + + local item = new("Item", fetchedItems[1].item_string) + assert.is_true(item.explicitModLines[1].prefix) + assert.is_true(item.explicitModLines[2].prefix) + assert.are.equal(item.explicitModLines[1].modGroup, item.explicitModLines[2].modGroup) + assert.is_true(item.explicitModLines[3].suffix) + assert.is_true(item.explicitModLines[4].suffix) + assert.are_not.equal(item.explicitModLines[3].modGroup, item.explicitModLines[4].modGroup) + local availability = new("TradeQuery", { itemsTab = { } }):GetBenchCraftAvailability(item) + assert.are.equal(2, availability.Prefix) + assert.are.equal(1, availability.Suffix) + end) end) describe("FetchResults", function() diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 68eaf5d32c7..4d30c82b941 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -60,6 +60,77 @@ describe("TradeQuery", function() end) assert.are.equal(0, #tooltip.lines) end) + + it("shows the simulated bench craft and its Ctrl compare hint", function() + local tq = newTradeQuery({ + resultTbl = { [1] = { [1] = { + item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring", + amount = 1, + currency = "chaos", + evaluation = { { + benchCraft = "+25 to Strength ^8(Suffix)", + benchCraftItemString = "Rarity: RARE\nBehemoth Hold\nGold Ring\nImplicits: 0\n{crafted}{suffix}+25 to Strength", + } }, + } } }, + sortedResultTbl = { [1] = { { index = 1 } } }, + }) + tq.itemsTab.AddItemTooltip = function() end + local dropdown = buildRow1Dropdown(tq) + local tooltip = new("Tooltip") + + dropdown.tooltipFunc(tooltip, "DROP", 1, nil) + + local tooltipText = "" + for _, line in ipairs(tooltip.lines) do + tooltipText = tooltipText .. (line.text or "") .. "\n" + end + assert.is_truthy(tooltipText:find("Bench craft: +25 to Strength", 1, true)) + assert.is_truthy(tooltipText:find("[Ctrl: compare]", 1, true)) + end) + + it("shows the simulated item and highlights its craft while Ctrl is held", function() + local tq = newTradeQuery({ + resultTbl = { [1] = { [1] = { + item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring\nImplicits: 0\n{prefix}+40 to maximum Mana", + amount = 1, + currency = "chaos", + evaluation = { { + benchCraft = "+25 to Strength ^8(Suffix)", + benchCraftItemString = "Rarity: RARE\nBehemoth Hold\nGold Ring\nImplicits: 0\n{prefix}+40 to maximum Mana\n{crafted}{suffix}+25 to Strength", + benchCraftLineIndexes = { 2 }, + } }, + } } }, + sortedResultTbl = { [1] = { { index = 1 } } }, + }) + tq.itemsTab.AddItemTooltip = function(_, tooltip, item) + for _, modLine in ipairs(item.explicitModLines or { }) do + tooltip:AddLine(16, colorCodes.MAGIC .. modLine.line, nil, modLine) + end + end + local previewActive = true + tq.IsBenchCraftPreviewActive = function() return previewActive end + local dropdown = buildRow1Dropdown(tq) + local tooltip = new("Tooltip") + + dropdown.tooltipFunc(tooltip, "DROP", 1, nil) + + assert.are.equal(1, #tooltip.childTooltips) + local previewText = "" + for _, line in ipairs(tooltip.childTooltips[1].lines) do + previewText = previewText .. (line.text or "") .. "\n" + end + assert.is_truthy(previewText:find("[Craft] +25 to Strength", 1, true)) + assert.is_truthy(previewText:find("Estimated with bench craft", 1, true)) + + previewActive = false + dropdown.tooltipFunc(tooltip, "DROP", 1, nil) + assert.is_nil(tooltip.childTooltips) + + previewActive = true + tq.resultTbl[1][1].evaluation = { { } } + dropdown.tooltipFunc(tooltip, "DROP", 1, nil) + assert.is_nil(tooltip.childTooltips) + end) end) describe("ReduceOutput", function() it("preserves lower-is-better values for weighted result comparison", function() @@ -114,4 +185,283 @@ describe("TradeQuery", function() assert.are.equals(1.2, result) end) end) + + describe("bench craft result evaluation", function() + local prefixCraft = { + type = "Prefix", + group = "IncreasedLife", + modTags = { "life" }, + types = { Ring = true }, + "+(51-55) to maximum Life", + } + local suffixCraft = { + type = "Suffix", + group = "Strength", + modTags = { "attribute" }, + types = { Ring = true }, + "+(21-25) to Strength", + } + + local function makeRareRing(prefixCount, suffixCount, extraLines) + local lines = { "Rarity: Rare", "Test Ring", "Sapphire Ring", "Implicits: 0" } + local prefixLines = { + "{prefix}+40 to maximum Mana", + "{prefix}20% increased Armour", + "{prefix}20% increased Evasion Rating", + } + local suffixLines = { + "{suffix}+30% to Fire Resistance", + "{suffix}+30% to Cold Resistance", + "{suffix}+30% to Lightning Resistance", + } + for index = 1, prefixCount do + table.insert(lines, prefixLines[index]) + end + for index = 1, suffixCount do + table.insert(lines, suffixLines[index]) + end + for _, line in ipairs(extraLines or { }) do + table.insert(lines, line) + end + return table.concat(lines, "\n") + end + + local function evaluate(itemString, crafts, calcOverride) + local tradeQuery = new("TradeQuery", { itemsTab = { } }) + tradeQuery.tradeQueryGenerator = mock_queryGen + tradeQuery.itemsTab.build = { data = { masterMods = crafts or { prefixCraft, suffixCraft } } } + tradeQuery.statSortSelectionList = { { stat = "Life", weightMult = 1 } } + tradeQuery.slotTables[1] = { slotName = "Ring 1", considerBenchCraft = true } + tradeQuery.resultTbl[1] = { { item_string = itemString } } + local function calc(args) + local life = 100 + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted and modLine.line:find("maximum Life", 1, true) then + life = 300 + elseif modLine.crafted and modLine.line:find("to Strength", 1, true) then + life = 150 + end + end + return { Life = life } + end + return tradeQuery:GetResultEvaluation(1, 1, calcOverride or calc, { Life = 100 })[1] + end + + it("only scores suffix crafts when the prefix side is full", function() + local evaluation = evaluate(makeRareRing(3, 2)) + + assert.are.equal(1.5, evaluation.weight) + assert.is_truthy(evaluation.benchCraft:find("to Strength", 1, true)) + assert.is_truthy(evaluation.benchCraftItemString:find("{crafted}", 1, true)) + assert.are.same({ 6 }, evaluation.benchCraftLineIndexes) + end) + + it("only scores prefix crafts when the suffix side is full", function() + local evaluation = evaluate(makeRareRing(2, 3)) + + assert.is_true(evaluation.weight > 1) + assert.is_truthy(evaluation.benchCraft:find("maximum Life", 1, true)) + end) + + it("previews the same craft roll that was used for scoring", function() + local scoredCraftLine + local evaluation = evaluate(makeRareRing(3, 2), { suffixCraft }, function(args) + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted then + scoredCraftLine = itemLib.applyRange(modLine.line, modLine.range, 1, 1) + return { Life = 200 } + end + end + return { Life = 100 } + end) + local previewItem = new("Item", evaluation.benchCraftItemString) + local previewModLine = previewItem.explicitModLines[evaluation.benchCraftLineIndexes[1]] + local previewCraftLine = itemLib.applyRange(previewModLine.line, previewModLine.range, 1, 1) + + assert.are.equal(scoredCraftLine, previewCraftLine) + assert.are.equal(main.defaultItemAffixQuality or 0.5, previewModLine.range) + end) + + it("does not add a second bench craft", function() + local evaluation = evaluate(makeRareRing(1, 1, { "{crafted}{suffix}+20 to Strength" })) + + assert.is_nil(evaluation.benchCraft) + end) + + it("allows another bench craft when the item has the multimod modifier", function() + local evaluation = evaluate(makeRareRing(1, 1, { + "{crafted}{suffix}Can have up to 3 Crafted Modifiers", + }), { prefixCraft }) + + assert.is_truthy(evaluation.benchCraft:find("maximum Life", 1, true)) + end) + + it("does not add a fourth craft when multimod affixes have distinct source indices", function() + local evaluation = evaluate(makeRareRing(1, 0, { + "{crafted}{suffix}{modGroup:trade:crafted:0}Can have up to 3 Crafted Modifiers", + "{crafted}{suffix}{modGroup:trade:crafted:1}+20% to Fire Resistance", + "{crafted}{suffix}{modGroup:trade:crafted:2}+20% to Cold Resistance", + }), { prefixCraft }) + + assert.are.equal(1, evaluation.weight) + assert.is_nil(evaluation.benchCraft) + end) + + it("does not score crafts on corrupted or mirrored items", function() + for _, marker in ipairs({ "Corrupted", "Mirrored" }) do + local evaluation = evaluate(makeRareRing(1, 1, { marker })) + assert.are.equal(1, evaluation.weight) + assert.is_nil(evaluation.benchCraft) + end + end) + + it("does not duplicate an existing affix group", function() + local evaluation = evaluate(makeRareRing(1, 3, { "{prefix}+50 to maximum Life" }), { prefixCraft }) + + assert.are.equal(1, evaluation.weight) + assert.is_nil(evaluation.benchCraft) + end) + + it("does not score an item when an explicit affix side is unknown", function() + local evaluation = evaluate(makeRareRing(0, 0, { "+50 to maximum Life" }), { suffixCraft }) + + assert.are.equal(1, evaluation.weight) + assert.is_nil(evaluation.benchCraft) + end) + + it("does not score an item without explicit affix metadata", function() + local evaluation = evaluate(makeRareRing(0, 0), { suffixCraft }) + + assert.are.equal(1, evaluation.weight) + assert.is_nil(evaluation.benchCraft) + end) + + it("does not score contradictory sides for the same trade affix", function() + local evaluation = evaluate(makeRareRing(0, 0, { + "{prefix}{modGroup:trade:explicit:0}+50 to maximum Life", + "{suffix}{modGroup:trade:explicit:0}+30% to Fire Resistance", + }), { suffixCraft }) + + assert.are.equal(1, evaluation.weight) + assert.is_nil(evaluation.benchCraft) + end) + + it("counts multi-line trade affixes once", function() + local independentPrefixCraft = copyTable(suffixCraft, true) + independentPrefixCraft.type = "Prefix" + local evaluation = evaluate(makeRareRing(1, 3, { + "{prefix}{modGroup:trade:explicit:0}+50 to maximum Life", + "{prefix}{modGroup:trade:explicit:0}20% increased Armour", + }), { independentPrefixCraft }) + + assert.is_true(evaluation.weight > 1) + assert.is_truthy(evaluation.benchCraft:find("to Strength", 1, true)) + end) + + it("reuses the parsed item without leaking prior craft candidates", function() + local crafts = { } + for index = 1, 25 do + table.insert(crafts, { + type = "Suffix", + group = "Candidate" .. index, + modTags = { "attribute" }, + types = { Ring = true }, + "+" .. index .. " to Strength", + }) + end + local calls = 0 + local maxCraftedLines = 0 + local evaluation = evaluate(makeRareRing(3, 2), crafts, function(args) + calls = calls + 1 + local craftedLines = 0 + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted then + craftedLines = craftedLines + 1 + end + end + maxCraftedLines = math.max(maxCraftedLines, craftedLines) + return { Life = 100 + craftedLines } + end) + + assert.are.equal(26, calls) + assert.are.equal(1, maxCraftedLines) + assert.is_truthy(evaluation.benchCraft) + end) + + it("keeps lower bench tiers when a higher tier has a worse trade-off", function() + local crafts = { + { + type = "Suffix", group = "FlaskTradeoff", level = 60, types = { Ring = true }, + "20% reduced Flask Charges gained", "(8-10)% increased Effect of Flasks on you", + }, + { + type = "Suffix", group = "FlaskTradeoff", level = 75, types = { Ring = true }, + "33% reduced Flask Charges gained", "(11-14)% increased Effect of Flasks on you", + }, + } + local evaluation = evaluate(makeRareRing(3, 2), crafts, function(args) + local life = 100 + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted and modLine.line:find("20% reduced", 1, true) then + life = 200 + elseif modLine.crafted and modLine.line:find("33% reduced", 1, true) then + life = 50 + end + end + return { Life = life } + end) + + assert.is_truthy(evaluation.benchCraft:find("20% reduced", 1, true)) + end) + + it("renders every line of a multi-line craft in the Ctrl preview", function() + local multiLineCraft = { + type = "Suffix", group = "FlaskTradeoff", types = { Ring = true }, + "20% reduced Flask Charges gained", "(8-10)% increased Effect of Flasks on you", + } + local originalItemString = makeRareRing(3, 2) + local evaluation = evaluate(originalItemString, { multiLineCraft }, function(args) + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted then + return { Life = 200 } + end + end + return { Life = 100 } + end) + local tooltipQuery = new("TradeQuery", { itemsTab = { } }) + tooltipQuery.itemsTab.activeItemSet = { } + tooltipQuery.itemsTab.slots = { } + tooltipQuery.slotTables[1] = { slotName = "Ring 1" } + tooltipQuery.resultTbl[1] = { { + item_string = originalItemString, + amount = 1, + currency = "chaos", + evaluation = { evaluation }, + } } + tooltipQuery.sortedResultTbl[1] = { { index = 1 } } + tooltipQuery.itemsTab.AddItemTooltip = function(_, tooltip, item) + for _, modLine in ipairs(item.explicitModLines or { }) do + local renderedLine = modLine.range + and itemLib.applyRange(modLine.line, modLine.range, modLine.valueScalar, modLine.corruptedRange) + or modLine.line + tooltip:AddLine(16, colorCodes.MAGIC .. renderedLine, nil, modLine) + end + end + tooltipQuery.IsBenchCraftPreviewActive = function() return true end + tooltipQuery:PriceItemRowDisplay(1, nil, 0, 20) + local tooltip = new("Tooltip") + + tooltipQuery.controls.resultDropdown1.tooltipFunc(tooltip, "DROP", 1, nil) + + assert.are.equal(originalItemString, tooltipQuery.resultTbl[1][1].item_string) + assert.are.equal(1, #tooltip.childTooltips) + local previewText = "" + for _, line in ipairs(tooltip.childTooltips[1].lines) do + previewText = previewText .. (line.text or "") .. "\n" + end + assert.is_truthy(previewText:find("[Craft] 20% reduced Flask Charges gained", 1, true)) + assert.is_truthy(previewText:find("9% increased Effect of Flasks on you", 1, true)) + end) + + end) end) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index aa7c29a4636..90a2d9688d5 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -777,6 +777,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 = {} @@ -791,6 +795,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] @@ -839,7 +1033,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 @@ -1187,6 +1391,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 @@ -1199,9 +1438,12 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end local item = new("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 f4a824c1f61..e9fd44d61d8 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1180,6 +1180,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", { "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 18 }, "Scourge Mods:", function(state) end) controls.includeScourge.state = (self.lastIncludeScourge == nil or self.lastIncludeScourge == true) @@ -1345,6 +1355,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 3bda7452a4a..d5a3294a0b4 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -335,13 +335,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)) @@ -355,7 +415,7 @@ function TradeQueryRequestsClass:FetchResultBlock(url, callback) t_insert(rawLines, processLine(modLine)) end for _, modLine in ipairs(item.explicitMods) do - t_insert(rawLines, processLine(modLine)) + t_insert(rawLines, processLine(modLine, true)) end if item.duplicated then t_insert(rawLines, "Mirrored") From f2d414d8c47c60e2918babd93caaeea53e876d3f Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 16:11:32 +0200 Subject: [PATCH 2/5] 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 4d30c82b941..30b4665b6ce 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -247,7 +247,7 @@ describe("TradeQuery", function() return tradeQuery:GetResultEvaluation(1, 1, calcOverride or calc, { Life = 100 })[1] end - it("only scores suffix crafts when the prefix side is full", function() + it("only evaluates suffix crafts when the prefix side is full", function() local evaluation = evaluate(makeRareRing(3, 2)) assert.are.equal(1.5, evaluation.weight) @@ -256,19 +256,19 @@ describe("TradeQuery", function() assert.are.same({ 6 }, evaluation.benchCraftLineIndexes) end) - it("only scores prefix crafts when the suffix side is full", function() + it("only evaluates prefix crafts when the suffix side is full", function() local evaluation = evaluate(makeRareRing(2, 3)) assert.is_true(evaluation.weight > 1) assert.is_truthy(evaluation.benchCraft:find("maximum Life", 1, true)) end) - it("previews the same craft roll that was used for scoring", function() - local scoredCraftLine + it("previews the same craft roll that was used for evaluation", function() + local evaluatedCraftLine local evaluation = evaluate(makeRareRing(3, 2), { suffixCraft }, function(args) for _, modLine in ipairs(args.repItem.explicitModLines or { }) do if modLine.crafted then - scoredCraftLine = itemLib.applyRange(modLine.line, modLine.range, 1, 1) + evaluatedCraftLine = itemLib.applyRange(modLine.line, modLine.range, 1, 1) return { Life = 200 } end end @@ -278,7 +278,7 @@ describe("TradeQuery", function() local previewModLine = previewItem.explicitModLines[evaluation.benchCraftLineIndexes[1]] local previewCraftLine = itemLib.applyRange(previewModLine.line, previewModLine.range, 1, 1) - assert.are.equal(scoredCraftLine, previewCraftLine) + assert.are.equal(evaluatedCraftLine, previewCraftLine) assert.are.equal(main.defaultItemAffixQuality or 0.5, previewModLine.range) end) @@ -307,7 +307,7 @@ describe("TradeQuery", function() assert.is_nil(evaluation.benchCraft) end) - it("does not score crafts on corrupted or mirrored items", function() + it("does not evaluate crafts on corrupted or mirrored items", function() for _, marker in ipairs({ "Corrupted", "Mirrored" }) do local evaluation = evaluate(makeRareRing(1, 1, { marker })) assert.are.equal(1, evaluation.weight) @@ -322,21 +322,21 @@ describe("TradeQuery", function() assert.is_nil(evaluation.benchCraft) end) - it("does not score an item when an explicit affix side is unknown", function() + it("does not evaluate an item when an explicit affix side is unknown", function() local evaluation = evaluate(makeRareRing(0, 0, { "+50 to maximum Life" }), { suffixCraft }) assert.are.equal(1, evaluation.weight) assert.is_nil(evaluation.benchCraft) end) - it("does not score an item without explicit affix metadata", function() + it("does not evaluate an item without explicit affix metadata", function() local evaluation = evaluate(makeRareRing(0, 0), { suffixCraft }) assert.are.equal(1, evaluation.weight) assert.is_nil(evaluation.benchCraft) end) - it("does not score contradictory sides for the same trade affix", function() + it("does not evaluate contradictory sides for the same trade affix", function() local evaluation = evaluate(makeRareRing(0, 0, { "{prefix}{modGroup:trade:explicit:0}+50 to maximum Life", "{suffix}{modGroup:trade:explicit:0}+30% to Fire Resistance", @@ -391,11 +391,11 @@ describe("TradeQuery", function() it("keeps lower bench tiers when a higher tier has a worse trade-off", function() local crafts = { { - type = "Suffix", group = "FlaskTradeoff", level = 60, types = { Ring = true }, + type = "Suffix", group = "FlaskEffectAndFlaskChargesGained", level = 60, types = { Ring = true }, "20% reduced Flask Charges gained", "(8-10)% increased Effect of Flasks on you", }, { - type = "Suffix", group = "FlaskTradeoff", level = 75, types = { Ring = true }, + type = "Suffix", group = "FlaskEffectAndFlaskChargesGained", level = 75, types = { Ring = true }, "33% reduced Flask Charges gained", "(11-14)% increased Effect of Flasks on you", }, } @@ -416,7 +416,7 @@ describe("TradeQuery", function() it("renders every line of a multi-line craft in the Ctrl preview", function() local multiLineCraft = { - type = "Suffix", group = "FlaskTradeoff", types = { Ring = true }, + type = "Suffix", group = "FlaskEffectAndFlaskChargesGained", types = { Ring = true }, "20% reduced Flask Charges gained", "(8-10)% increased Effect of Flasks on you", } local originalItemString = makeRareRing(3, 2) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index e9fd44d61d8..cef7055d4f1 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1185,7 +1185,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 1684523e219f295e3999056b7a909827a430567e Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 17:56:19 +0200 Subject: [PATCH 3/5] 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 30b4665b6ce..4674c166c37 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -288,7 +288,7 @@ describe("TradeQuery", function() assert.is_nil(evaluation.benchCraft) end) - it("allows another bench craft when the item has the multimod modifier", function() + it("allows another bench craft with multiple crafted modifiers", function() local evaluation = evaluate(makeRareRing(1, 1, { "{crafted}{suffix}Can have up to 3 Crafted Modifiers", }), { prefixCraft }) @@ -296,7 +296,7 @@ describe("TradeQuery", function() assert.is_truthy(evaluation.benchCraft:find("maximum Life", 1, true)) end) - it("does not add a fourth craft when multimod affixes have distinct source indices", function() + it("does not add a fourth craft when crafted modifiers have distinct source indices", function() local evaluation = evaluate(makeRareRing(1, 0, { "{crafted}{suffix}{modGroup:trade:crafted:0}Can have up to 3 Crafted Modifiers", "{crafted}{suffix}{modGroup:trade:crafted:1}+20% to Fire Resistance", From 807c747a3fc1dee3475482d9d79bf331fe6818e2 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 18:09:28 +0200 Subject: [PATCH 4/5] 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 4674c166c37..7443167dfb0 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -263,6 +263,20 @@ describe("TradeQuery", function() assert.is_truthy(evaluation.benchCraft:find("maximum Life", 1, true)) end) + it("does not evaluate crafts unavailable for the item type", function() + local amuletCraft = { + type = "Prefix", + group = "IncreasedLife", + modTags = { "life" }, + types = { Amulet = true }, + "+(51-55) to maximum Life", + } + local evaluation = evaluate(makeRareRing(2, 2), { amuletCraft }) + + assert.are.equal(1, evaluation.weight) + assert.is_nil(evaluation.benchCraft) + end) + it("previews the same craft roll that was used for evaluation", function() local evaluatedCraftLine local evaluation = evaluate(makeRareRing(3, 2), { suffixCraft }, function(args) From 168bce48c111d41861fe8f99de26e643abc27b52 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 18:51:57 +0200 Subject: [PATCH 5/5] 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 7443167dfb0..d9c8c2ec611 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -88,6 +88,34 @@ describe("TradeQuery", function() assert.is_truthy(tooltipText:find("[Ctrl: compare]", 1, true)) end) + it("identifies a replaced bench craft in the result tooltip", function() + local tq = newTradeQuery({ + resultTbl = { [1] = { [1] = { + item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring", + amount = 1, + currency = "chaos", + evaluation = { { + benchCraft = "+25 to Strength ^8(Suffix)", + benchCraftReplaced = "+20 to Dexterity ^8(Suffix)", + benchCraftItemString = "Rarity: RARE\nBehemoth Hold\nGold Ring\nImplicits: 0\n{crafted}{suffix}+25 to Strength", + } }, + } } }, + sortedResultTbl = { [1] = { { index = 1 } } }, + }) + tq.itemsTab.AddItemTooltip = function() end + local dropdown = buildRow1Dropdown(tq) + local tooltip = new("Tooltip") + + dropdown.tooltipFunc(tooltip, "DROP", 1, nil) + + local tooltipText = "" + for _, line in ipairs(tooltip.lines) do + tooltipText = tooltipText .. (line.text or "") .. "\n" + end + assert.is_truthy(tooltipText:find("Replace craft: +20 to Dexterity", 1, true)) + assert.is_truthy(tooltipText:find("-> +25 to Strength", 1, true)) + end) + it("shows the simulated item and highlights its craft while Ctrl is held", function() local tq = newTradeQuery({ resultTbl = { [1] = { [1] = { @@ -296,10 +324,43 @@ describe("TradeQuery", function() assert.are.equal(main.defaultItemAffixQuality or 0.5, previewModLine.range) end) - it("does not add a second bench craft", function() - local evaluation = evaluate(makeRareRing(1, 1, { "{crafted}{suffix}+20 to Strength" })) + it("replaces an existing bench craft when the item is otherwise full", function() + local evaluation = evaluate(makeRareRing(3, 2, { "{crafted}{suffix}+20 to Dexterity" }), { suffixCraft }) + + assert.is_truthy(evaluation.benchCraft:find("to Strength", 1, true)) + assert.is_truthy(evaluation.benchCraftReplaced:find("+20 to Dexterity", 1, true)) + assert.is_nil(evaluation.benchCraftItemString:find("+20 to Dexterity", 1, true)) + end) + + it("keeps an existing bench craft when every replacement is worse", function() + local evaluation = evaluate(makeRareRing(1, 1, { "{crafted}{prefix}+50 to maximum Life" }), { suffixCraft }) + assert.is_true(evaluation.weight > 1) assert.is_nil(evaluation.benchCraft) + assert.is_nil(evaluation.benchCraftReplaced) + end) + + it("removes every line of a replaced multi-line craft", function() + local evaluation = evaluate(makeRareRing(1, 1, { + "{crafted}{prefix}{modGroup:trade:crafted:0}+20 to Dexterity", + "{crafted}{prefix}{modGroup:trade:crafted:0}10% increased Rarity of Items found", + }), { suffixCraft }) + + assert.is_truthy(evaluation.benchCraftReplaced:find("to Dexterity/10% increased Rarity", 1, true)) + assert.is_nil(evaluation.benchCraftItemString:find("+20 to Dexterity", 1, true)) + assert.is_nil(evaluation.benchCraftItemString:find("10% increased Rarity", 1, true)) + end) + + it("tracks the replacement preview line after a full item reparse", function() + local itemString = makeRareRing(3, 2, { "{crafted}{suffix}+20 to Dexterity" }) + :gsub("Implicits: 0", "Catalyst: Intrinsic\nCatalystQuality: 20\nImplicits: 0") + local evaluation = evaluate(itemString, { suffixCraft }) + local previewItem = new("Item", evaluation.benchCraftItemString) + local previewModLine = previewItem.explicitModLines[evaluation.benchCraftLineIndexes[1]] + + assert.is_true(previewModLine.crafted) + assert.is_truthy(previewModLine.line:find("to Strength", 1, true)) + assert.is_nil(evaluation.benchCraftItemString:find("+20 to Dexterity", 1, true)) end) it("allows another bench craft with multiple crafted modifiers", function() @@ -308,6 +369,7 @@ describe("TradeQuery", function() }), { prefixCraft }) assert.is_truthy(evaluation.benchCraft:find("maximum Life", 1, true)) + assert.is_nil(evaluation.benchCraftReplaced) end) it("does not add a fourth craft when crafted modifiers have distinct source indices", function() diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 90a2d9688d5..ccd65bb4d8b 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -866,13 +866,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) @@ -920,22 +924,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 @@ -968,6 +1008,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) @@ -982,7 +1023,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 @@ -1033,9 +1074,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, @@ -1043,6 +1084,7 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba benchCraft = benchCraft, benchCraftItemString = benchCraftItemString, benchCraftLineIndexes = benchCraftLineIndexes, + benchCraftReplaced = benchCraftReplaced, }} end return result.evaluation @@ -1397,8 +1439,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 cef7055d4f1..50d7a7221ac 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1184,8 +1184,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