diff --git a/spec/System/TestTradeQueryRequests_spec.lua b/spec/System/TestTradeQueryRequests_spec.lua index f8e53ea5e7..f31e1bc03d 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 68eaf5d32c..d9c8c2ec61 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -60,6 +60,105 @@ 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("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] = { + 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 +213,331 @@ 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 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)) + + assert.is_true(evaluation.weight > 1) + 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) + for _, modLine in ipairs(args.repItem.explicitModLines or { }) do + if modLine.crafted then + evaluatedCraftLine = 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(evaluatedCraftLine, previewCraftLine) + assert.are.equal(main.defaultItemAffixQuality or 0.5, previewModLine.range) + end) + + 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() + 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)) + assert.is_nil(evaluation.benchCraftReplaced) + end) + + 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", + "{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 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) + 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 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) + 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) + 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 = "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", + }, + } + 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 = "FlaskEffectAndFlaskChargesGained", 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 aa7c29a463..ccd65bb4d8 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,237 @@ 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 + local craftState = { + count = craftedCount, + limit = craftedLimit, + } + if craftedCount >= craftedLimit then + 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) + 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 + +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, 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(evaluationItem) + local existingGroups = getExistingAffixGroups(evaluationItem, existingLines) + local bestCraft + local bestCraftItemString + local bestCraftLineIndexes + 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[evaluationItem.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() + bestReplacedCraft = replacedCraft + 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, bestReplacedCraft +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 +1074,18 @@ 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, benchCraftReplaced + if slotTbl.considerBenchCraft then + output, weight, benchCraft, benchCraftItemString, benchCraftLineIndexes, benchCraftReplaced = self:GetBestBenchCraftEvaluation(item, slotName, calcFunc, baseOutput, output, weight) + end + result.evaluation = {{ + output = output, + weight = weight, + benchCraft = benchCraft, + benchCraftItemString = benchCraftItemString, + benchCraftLineIndexes = benchCraftLineIndexes, + benchCraftReplaced = benchCraftReplaced, + }} end return result.evaluation end @@ -1187,6 +1433,44 @@ 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 "" + local craftLabel = evaluation.benchCraftReplaced + and "^7Replace craft: " .. evaluation.benchCraftReplaced .. " -> " + or "^7Bench craft: " + tooltip:AddSeparator(10) + tooltip:AddLine(16, craftLabel .. 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 +1483,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 f4a824c1f6..50d7a7221a 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 }, "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 + 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 3bda7452a4..d5a3294a0b 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")