diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 53a14f3d98..401a4f89f8 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 e11ea701b9..758352b675 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -153,6 +153,136 @@ describe("TradeQueryGenerator", function() end) end) + describe("EstimateBenchCraftWeight", function() + 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", + } + 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 crafts = queryGen:GetHighestLevelBenchCrafts({ low, firstHigh, secondHigh }) + + assert.are.same({ firstHigh }, crafts) + 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 } } + + local evaluationPlan = queryGen:CreateBenchCraftEvaluationPlan(statWeights) + queryGen.modWeights[1].weight = 20 + statWeights[1].weightMult = 10 + + 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() local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { items = {} } }) @@ -191,5 +321,57 @@ describe("TradeQueryGenerator", function() assert.is_not_nil(query.filters.socket_filters.filters.sockets) assert.is_not_nil(query.filters.socket_filters.filters.links) end) + + it("ranks the single empty affix weight with regular filters before applying MAX_FILTERS", function() + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { items = { } } }) + queryGen.modWeights = { } + for index = 1, 40 do + table.insert(queryGen.modWeights, { + tradeModId = "explicit.stat_" .. index, + weight = 1, + meanStatDiff = 41 - index, + }) + end + queryGen.calcContext = { + testItem = new("Item"):Item("Rarity: RARE\nNew Item\nGold Ring\nImplicits: 0"), + baseOutput = { }, + baseStatValue = 0, + itemCategoryQueryStr = "accessory.ring", + special = { }, + options = { + statWeights = { }, + influence1 = 1, + influence2 = 1, + includeMirrored = false, + sockets = 6, + links = 6, + }, + } + queryGen.tradeTypeIndex = 1 + queryGen.requesterContext = { slotTbl = { considerBenchCraft = true } } + local receivedPlan + queryGen.GetBenchCraftQueryFilter = function(_, _, evaluationPlan) + receivedPlan = evaluationPlan + return { + id = "pseudo.pseudo_number_of_empty_affix_mods", + value = { min = 1, max = 1, weight = 35.5 }, + }, 35.5 + end + local query + queryGen.requesterCallback = function(_, queryJson) + query = require("dkjson").decode(queryJson).query + end + + queryGen:FinishQuery() + local filtersById = { } + for _, filter in ipairs(query.stats[1].filters) do + filtersById[filter.id] = filter + end + assert.are.equal(31, #query.stats[1].filters) + assert.are.equal(35.5, filtersById["pseudo.pseudo_number_of_empty_affix_mods"].value.weight) + assert.is_not_nil(filtersById["explicit.stat_30"]) + assert.is_nil(filtersById["explicit.stat_31"]) + assert.are.equal(receivedPlan, queryGen.requesterContext.benchCraftEvaluationPlan) + end) end) end) diff --git a/spec/System/TestTradeQueryRequests_spec.lua b/spec/System/TestTradeQueryRequests_spec.lua index a872f1ecf9..598165712c 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"):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) + end) end) describe("FetchResults", function() diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 9a83a331c4..ce3bae99d4 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -6,6 +6,135 @@ 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 = {} }) + 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 @@ -60,6 +189,130 @@ 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"):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"):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"):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("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() @@ -114,4 +367,549 @@ 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, 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 }, + 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 } + end + 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() + 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("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) + 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", + 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"):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 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 }) + local previewItem = new("Item"):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("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 }) + + 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("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 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 + mock_queryGen.EstimateBenchCraftWeight = function(_, craft, receivedPlan) + assert.are.equal(evaluationPlan, receivedPlan) + 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, 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", + } + local dexterityCraft = { + type = "Suffix", group = "Dexterity", types = { Ring = true }, "+2 to Dexterity", + } + local stalePlan = { + statWeights = { { stat = "Life", weightMult = 2 } }, + } + local originalEstimator = mock_queryGen.EstimateBenchCraftWeight + 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 } + 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)) + end) + + 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", + }, + } + 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 } + 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 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"):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"):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/Item.lua b/src/Classes/Item.lua index 3197074d83..83b6e806f6 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -61,6 +61,40 @@ local function getCatalystScalar(catalystId, mod, quality) return 1 end +local function modMatchesMagnitude(mod, modType, magnitudeMod) + if magnitudeMod.modType and magnitudeMod.modType ~= modType then + return false + end + local tagLookup = { } + for _, tag in ipairs(mod.modTags or { }) do + tagLookup[tag] = true + end + -- These are not actual mod tags, but do appear in modifier magnitude mods. + for _, lineFlag in ipairs({ "unveiled", "prefix", "suffix" }) do + if mod[lineFlag] then + tagLookup[lineFlag] = true + end + end + for _, tag in ipairs(magnitudeMod.tags or { }) do + if not tagLookup[tag] then + return false + end + end + if magnitudeMod.anyTags then + local anyTagMatches = false + for _, tag in ipairs(magnitudeMod.anyTags) do + if tagLookup[tag] then + anyTagMatches = true + break + end + end + if not anyTagMatches then + return false + end + end + return true +end + local function normaliseModLine(line) return line:gsub("%d+%.?%d*", "#") :gsub("%(%-?#%-#%)", "#"):lower() @@ -98,6 +132,24 @@ function ItemClass:Item(raw, rarity, highQuality) return self end +-- Accepts parsed lines and proposed mods; modType selects applicable modifier-magnitude effects. +function ItemClass:GetModLineValueScalar(mod, modType) + local valueScalar = getCatalystScalar(self.catalyst, mod, self.catalystQuality) + if mod.unscalable then + return valueScalar + end + for _, magnitudeMod in ipairs(self.modMagnitudeMods or { }) do + if modMatchesMagnitude(mod, modType, magnitudeMod) then + if magnitudeMod.multiplier then + valueScalar = valueScalar * magnitudeMod.multiplier + else + valueScalar = valueScalar + magnitudeMod.quality / 100 + end + end + end + return valueScalar +end + -- Reset all influence keys to false function ItemClass:ResetInfluence() for _, curInfluenceInfo in ipairs(influenceInfo) do @@ -1381,54 +1433,22 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) if self.advancedCopy or self.crafted then -- apply mod magnitude boost to matching mods if #self.modMagnitudeMods > 0 then - for _, modMagnitudeMod in ipairs(self.modMagnitudeMods) do - local modLists - if modMagnitudeMod.modType then - modLists = { self[modMagnitudeMod.modType .. "ModLines"] } - else - modLists = { self.implicitModLines, self.explicitModLines, self.enchantModLines } - end - for _, mods in ipairs(modLists) do - for _, mod in ipairs(mods or {}) do - -- avoid scaling variant lines which are not active - if mod.variantList and (self:GetModLineVariantCount(mod) == 0) then - goto modMagnitudeContinue - end - -- Create a fast lookup table for all provided tags - local tagLookup = {} - for _, curTag in ipairs(mod.modTags) do - tagLookup[curTag] = true; - end - -- these aren't actual mod tags but do appear in mod magnitude mods - for _, lineFlag in ipairs({ "unveiled", "prefix", "suffix" }) do - if mod[lineFlag] then - tagLookup[lineFlag] = true - end - end - local match = true - for _, magnitudeTag in ipairs(modMagnitudeMod.tags) do - if not tagLookup[magnitudeTag] then - match = false - end - end - if modMagnitudeMod.anyTags and not (tagLookup[modMagnitudeMod.anyTags[1]] or tagLookup[modMagnitudeMod.anyTags[2]]) then - match = false - end - if match and not mod.unscalable then - if modMagnitudeMod.multiplier then - mod.valueScalar = (mod.valueScalar or 1) * modMagnitudeMod.multiplier - else - mod.valueScalar = (mod.valueScalar or 1) + (modMagnitudeMod.quality / 100) - end - end - if mod.valueScalar and mod.valueScalar ~= 1 then + for _, entry in ipairs({ + { modType = "implicit", mods = self.implicitModLines }, + { modType = "explicit", mods = self.explicitModLines }, + { modType = "enchant", mods = self.enchantModLines }, + }) do + for _, mod in ipairs(entry.mods or { }) do + -- Avoid scaling variant lines which are not active. + if not mod.variantList or self:GetModLineVariantCount(mod) > 0 then + mod.valueScalar = self:GetModLineValueScalar(mod, entry.modType) + if mod.valueScalar ~= 1 then local rangedLine = itemLib.applyRange(mod.line, mod.range or 1, mod.valueScalar, 1) local modList, extra = modLib.parseMod(rangedLine) mod.displayValueScalar = 1 mod.modList = modList mod.extra = extra end - ::modMagnitudeContinue:: end end end diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 7ea2fcedf0..c823aa53f6 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -58,6 +58,14 @@ function TradeQueryClass:TradeQuery(itemsTab) self.backoffFinish = nil -- last query for each row self.lastQueries = {} + -- Result evaluation is resumed one calculation at a time so expensive + -- build comparisons never monopolise the UI thread after a fetch. + self.resultEvaluationContexts = {} + self.resultEvaluationQueue = {} + self.resultEvaluationQueued = {} + -- Identity tokens keep network fetches separate from result evaluation and + -- prevent an older response from replacing a newer search. + self.resultFetchContexts = {} self.tradeQueryRequests = new("TradeQueryRequests"):TradeQueryRequests() if not main.api then @@ -443,7 +451,9 @@ on trade site to work on other leagues and realms)]] self.controls.itemSortSelection = new("DropDownControl"):DropDownControl({"TOPRIGHT", self.controls.StatWeightMultipliersButton, "TOPLEFT"}, {-8, 0, 170, row_height}, self.itemSortSelectionList, function(index, value) self.pbItemSortSelectionIndex = index for row_idx, _ in pairs(self.resultTbl) do - self:UpdateControlsWithItems(row_idx) + if not self.resultFetchContexts[row_idx] then + self:StartResultEvaluation(row_idx) + end end end) self.controls.itemSortSelection.tooltipText = @@ -654,6 +664,7 @@ Highest Weight - Displays the order retrieved from trade]] end main.onFrameFuncs["TradeQueryRequests"] = function() self.tradeQueryRequests:ProcessQueue(onRateLimit) + self:ProcessResultEvaluations() if self.countDown then coroutine.resume(self.countDown) if coroutine.status(self.countDown) == "dead" then @@ -747,7 +758,9 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) self.statSortSelectionList = statSortSelectionList end for row_idx in pairs(self.resultTbl) do - self:UpdateControlsWithItems(row_idx) + if not self.resultFetchContexts[row_idx] then + self:StartResultEvaluation(row_idx) + end end end) controls.cancel = new("ButtonControl"):ButtonControl({ "BOTTOM", nil, "BOTTOM" }, { 0, -10, 80, 20 }, "Cancel", function() @@ -781,6 +794,135 @@ 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 + -- Method to reduce the full output to only the values that were 'weighted' function TradeQueryClass:ReduceOutput(output) local smallOutput = {} @@ -795,25 +937,294 @@ function TradeQueryClass:ReduceOutput(output) return smallOutput 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] - if not calcFunc then -- Always evaluate when calcFunc is given - calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() - local onlyWeightedBaseOutput = self:ReduceOutput(baseOutput) - if not self.onlyWeightedBaseOutput[row_idx] then - self.onlyWeightedBaseOutput[row_idx] = { } +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, benchGroups) + 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 + -- 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 + 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 - if not self.lastComparedWeightList[row_idx] then - self.lastComparedWeightList[row_idx] = { } + 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(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"):Item(strippedItem:BuildRaw()), replacedCraft +end + +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 + 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 bestCraft + local bestCraftItemString + local bestCraftLineIndexes + local bestReplacedCraft + 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 + 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 - -- 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 + for _ = 1, #craft do + t_remove(craftedItem.explicitModLines, #craftedItem.explicitModLines) end - self.onlyWeightedBaseOutput[row_idx][result_index] = onlyWeightedBaseOutput - self.lastComparedWeightList[row_idx][result_index] = self.statSortSelectionList 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 resultRow = self.resultTbl[row_idx] + local result = resultRow[result_index] + if not calcFunc then + calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() + 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 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]) + and tableDeepEquals(self.statSortSelectionList, self.lastComparedWeightList[row_idx][result_index]) then + return result.evaluation + end + self.onlyWeightedBaseOutput[row_idx][result_index] = onlyWeightedBaseOutput + self.lastComparedWeightList[row_idx][result_index] = self.statSortSelectionList local slotTbl = self.slotTables[row_idx] local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId local slotName = jewelNodeId and "Jewel " .. tostring(jewelNodeId) or slotTbl.slotName @@ -822,10 +1233,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 @@ -842,8 +1260,26 @@ 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) - result.evaluation = {{ output = output, weight = weight }} + local benchCraft, benchCraftItemString, benchCraftLineIndexes, benchCraftReplaced + if slotTbl.considerBenchCraft then + 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, evaluationPlan, yieldFunc) + end + result.evaluation = {{ + output = output, + weight = weight, + benchCraft = benchCraft, + benchCraftItemString = benchCraftItemString, + benchCraftLineIndexes = benchCraftLineIndexes, + benchCraftReplaced = benchCraftReplaced, + }} end return result.evaluation end @@ -866,6 +1302,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 @@ -873,12 +1310,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 @@ -915,14 +1352,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 @@ -944,7 +1395,7 @@ function TradeQueryClass:SortFetchResults(row_idx, mode) 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 @@ -1021,6 +1472,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) @@ -1034,13 +1486,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, "") @@ -1067,12 +1521,15 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro itemsSafe[i].item_string = item:BuildRaw() end + itemsSafe.benchCraftEvaluationPlan = context.benchCraftEvaluationPlan 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 @@ -1128,12 +1585,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 @@ -1142,18 +1602,18 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite local selectedSlot = getSelectedSlot() local itemsSafe = self:FilterToSafeItems(items, selectedSlot and selectedSlot.slotName) self.resultTbl[row_idx] = itemsSafe - self:UpdateControlsWithItems(row_idx) + self:StartResultEvaluation(row_idx) end - controls["priceButton"..row_idx].label = "Price Item" end) end) controls["priceButton"..row_idx].enabled = function() local isAuthorized = main.api.authToken ~= nil local validURL = controls["uri"..row_idx].validURL - local isSearching = controls["priceButton"..row_idx].label == "Searching..." + local isSearching = self.resultFetchContexts[row_idx] ~= nil + local isEvaluating = self.resultEvaluationContexts[row_idx] ~= nil local selectedJewelSlot = slotTbl.selectedJewelNodeId and self.itemsTab.sockets[slotTbl.selectedJewelNodeId] local hasRequiredJewelSlot = not slotTbl.unique or selectedJewelSlot and not selectedJewelSlot.inactive - return isAuthorized and validURL and not isSearching and hasRequiredJewelSlot + return isAuthorized and validURL and not isSearching and not isEvaluating and hasRequiredJewelSlot end controls["priceButton"..row_idx].tooltipFunc = function(tooltip) tooltip:Clear() @@ -1191,6 +1651,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"):Item(evaluation.benchCraftItemString) + local previewTooltip = tooltip.benchCraftPreviewTooltip or new("Tooltip"):Tooltip() + tooltip.benchCraftPreviewTooltip = previewTooltip + previewTooltip:Clear() + self.itemsTab:AddItemTooltip(previewTooltip, previewItem, tooltipSlot) + local craftedModLines = { } + for _, lineIndex in ipairs(evaluation.benchCraftLineIndexes or { }) do + local modLine = previewItem.explicitModLines[lineIndex] + if modLine then + craftedModLines[modLine] = true + end + end + for _, line in ipairs(previewTooltip.lines) do + if line.modLine and craftedModLines[line.modLine] and line.text then + line.text = colorCodes.WARNING .. "[Craft] " .. StripEscapes(line.text) + end + end + previewTooltip:AddSeparator(10) + previewTooltip:AddLine(14, colorCodes.TIP .. "Estimated with bench craft.") + tooltip.childTooltips = { previewTooltip } + end controls["resultDropdown"..row_idx].tooltipFunc = function(tooltip, dropdown_mode, dropdown_index, dropdown_display_string) local sortedRow = self.sortedResultTbl[row_idx] if not sortedRow or not sortedRow[dropdown_index] then @@ -1203,14 +1701,26 @@ 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 + 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) @@ -1226,21 +1736,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 @@ -1256,7 +1768,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 @@ -1284,7 +1799,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 9b7e3f5b40..aeb32e653b 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -204,6 +204,127 @@ function TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, newOutput, st return meanStatDiff end +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 { + statWeights = copyTable(statWeights or { }), + weightsByTradeMod = weightsByTradeMod, + craftWeights = { }, + } +end + +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 + 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 + 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 and cached * valueScalar 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 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) + 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 + 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) 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 @@ -886,6 +1007,9 @@ function TradeQueryGeneratorClass:FinishQuery() end return a.meanStatDiff > b.meanStatDiff end) + local benchCraftEvaluationPlan = self.requesterContext and self.requesterContext.slotTbl + and self.requesterContext.slotTbl.considerBenchCraft + and self:CreateBenchCraftEvaluationPlan(self.calcContext.options.statWeights) or nil -- A megalomaniac is not being compared to anything and the currentStatDiff will be 0, so just go for an arbitrary min weight - in this case triple the weight of the worst evaluated node. local megalomaniacSpecialMinWeight = self.calcContext.special.itemName == "Megalomaniac" and self.modWeights[#self.modWeights] * 3 @@ -991,7 +1115,16 @@ function TradeQueryGeneratorClass:FinishQuery() ignoredStats[tostring(HashStats(stats))] = true end end - local statFilters = {} + local statFilterEntries = { } + local function addStatFilter(filterEntry, priority) + local entry = { + filter = filterEntry, + priority = priority or 0, + order = #statFilterEntries + 1, + } + t_insert(statFilterEntries, entry) + return entry + end local pseudoMods = {} for _, entry in ipairs(self.modWeights) do local hash = entry.tradeModId:match("stat_(%d+)") @@ -1005,17 +1138,19 @@ function TradeQueryGeneratorClass:FinishQuery() filterEntry.id = tradeId -- avoid adding duplicate pseudo filters: update existing if pseudoMods[tradeId] then - pseudoMods[tradeId].value.weight = math.max(filterEntry.value.weight, pseudoMods[tradeId].value.weight) + local existingEntry = pseudoMods[tradeId] + existingEntry.filter.value.weight = math.max(filterEntry.value.weight, existingEntry.filter.value.weight) + existingEntry.priority = m_max(existingEntry.priority, entry.meanStatDiff or 0) else - pseudoMods[tradeId] = filterEntry - table.insert(statFilters, filterEntry) + pseudoMods[tradeId] = addStatFilter(filterEntry, entry.meanStatDiff) end else - table.insert(statFilters, filterEntry) + addStatFilter(filterEntry, entry.meanStatDiff) end ::weightContinue:: end + local benchCraftQueryFilter, benchCraftQueryPriority = self:GetBenchCraftQueryFilter(self.calcContext.testItem, benchCraftEvaluationPlan) for k, v in pairs(self.calcContext.special.queryExtra or {}) do queryTable.query[k] = v @@ -1036,8 +1171,17 @@ function TradeQueryGeneratorClass:FinishQuery() t_insert(queryTable.query.stats, andFilters) end - for _, entry in ipairs(statFilters) do - t_insert(queryTable.query.stats[1].filters, entry) + if benchCraftQueryFilter then + addStatFilter(benchCraftQueryFilter, benchCraftQueryPriority) + end + table.sort(statFilterEntries, function(a, b) + if a.priority == b.priority then + return a.order < b.order + end + return a.priority > b.priority + end) + for _, entry in ipairs(statFilterEntries) do + t_insert(queryTable.query.stats[1].filters, entry.filter) filters = filters + 1 if filters == effective_max then break @@ -1119,6 +1263,9 @@ function TradeQueryGeneratorClass:FinishQuery() end local queryJson = dkjson.encode(queryTable) + if self.requesterContext then + self.requesterContext.benchCraftEvaluationPlan = benchCraftEvaluationPlan + end self.requesterCallback(self.requesterContext, queryJson, errMsg) -- Close blocker popup @@ -1184,6 +1331,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"):CheckBoxControl({ "TOPRIGHT", lastItemAnchor, "BOTTOMRIGHT" }, + { 0, 5, 18 }, "Bench Craft:", function(state) end, + "Searches for one empty affix and ranks each result using its best compatible bench craft or replacement.") + 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) @@ -1215,7 +1372,7 @@ Remove: eldritch implicits are removed and ignored in the search.]] controls.includeEldritch = new("DropDownControl"):DropDownControl({ "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 140, 18 }, { "Copy Current", "Keep regular", "Keep regular+presence", "Remove" }, function(_state) end, eldritchTooltip) controls.includeEldritchLabel = new("LabelControl"):LabelControl({ "RIGHT", controls.includeEldritch, "LEFT" }, - { -4, 0, 80, 16 }, "Eldritch Mods:") + { -4, 0, 80, 16 }, "^7Eldritch Mods:") controls.includeEldritch:SelByValue(self.lastIncludeEldritch) updateLastAnchor(controls.includeEldritch) end @@ -1348,6 +1505,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 b006712380..cab9d24285 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -337,13 +337,73 @@ function TradeQueryRequestsClass:FetchResultBlock(url, callback) end end - local function processLine(modLine) + local groupsByDomain = { } + for _, domain in ipairs({ "explicit", "crafted" }) do + local groupsByHash = { } + for _, entry in ipairs(item.extended and item.extended.hashes and item.extended.hashes[domain] or { }) do + if type(entry) == "table" and type(entry[1]) == "string" and type(entry[2]) == "table" then + if groupsByHash[entry[1]] ~= nil then + groupsByHash[entry[1]] = false + else + groupsByHash[entry[1]] = entry[2] + end + end + end + groupsByDomain[domain] = groupsByHash + end + + local function getTradeAffixMetadata(modLine) + -- ItemMod flags do not include affix sides; source-mod tiers use P/S. + -- Extended hash indices identify the source affix across multi-line stats. + local affixSide + local mods = type(modLine.mods) == "table" and modLine.mods or { } + for _, mod in ipairs(mods) do + local side = mod.tier and mod.tier:sub(1, 1) + if side ~= "P" and side ~= "S" then + return + elseif affixSide and affixSide ~= side then + return + end + affixSide = side + end + local domain = modLine.domain + local uniqueMod = #mods == 1 and mods[1] or nil + local magnitude = uniqueMod and uniqueMod.magnitudes and uniqueMod.magnitudes[1] + local rawHash = modLine.hash or uniqueMod and uniqueMod.hash or magnitude and magnitude.hash + local hash = type(rawHash) == "string" and rawHash:gsub("^stat%.", "") + local groupIndices = groupsByDomain[domain] and groupsByDomain[domain][hash] + if not affixSide or type(groupIndices) ~= "table" or #groupIndices == 0 then + return + end + local affixIds = { } + local seenIndices = { } + for _, index in ipairs(groupIndices) do + if type(index) ~= "number" or seenIndices[index] then + return + end + seenIndices[index] = true + t_insert(affixIds, domain .. ":" .. index) + end + table.sort(affixIds) + return affixSide == "P" and "prefix" or "suffix", "trade:" .. table.concat(affixIds, "|") + end + + local function processLine(modLine, includeAffixMetadata) local s = "" for flagName, flag in pairs(modLine.flags or {}) do if flag then s = s .. string.format("{%s}", flagName) end end + if modLine.domain == "crafted" and not (modLine.flags and modLine.flags.crafted) then + s = s .. "{crafted}" + end + if includeAffixMetadata then + local affixSide, affixGroup = getTradeAffixMetadata(modLine) + if affixSide then + s = s .. string.format("{%s}{modGroup:%s}", affixSide, affixGroup) + end + end return s .. escapeGGGString(modLine.description) end t_insert(rawLines, "Implicits: " .. (#item.enchantMods + #item.scourgeMods + #item.implicitMods)) @@ -357,7 +417,7 @@ function TradeQueryRequestsClass:FetchResultBlock(url, callback) t_insert(rawLines, processLine(modLine)) end for _, modLine in ipairs(item.explicitMods) do - t_insert(rawLines, processLine(modLine)) + t_insert(rawLines, processLine(modLine, true)) end if item.duplicated then t_insert(rawLines, "Mirrored")