From cc777176fe684580bd5efaa9459583cd74055114 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Wed, 5 Aug 2026 00:36:25 +0200 Subject: [PATCH 1/8] Add resistance-aware Trader searches Separate resistance swaps from cap requirements so searches can broaden elemental candidates without overvaluing excess resistance. Validate fetched permutations against the build's actual elemental and Chaos caps while preserving the listed item for import. --- spec/System/TestTradeQueryGenerator_spec.lua | 301 ++++++++++++++++ spec/System/TestTradeQueryRequests_spec.lua | 197 +++++++++++ spec/System/TestTradeQuery_spec.lua | 351 +++++++++++++++++++ src/Classes/TradeQuery.lua | 158 +++++++-- src/Classes/TradeQueryGenerator.lua | 81 ++++- src/Classes/TradeQueryRequests.lua | 13 +- src/Classes/TradeResistanceGrouping.lua | 112 ++++++ src/Classes/TradeResistanceSwap.lua | 223 ++++++++++++ 8 files changed, 1403 insertions(+), 33 deletions(-) create mode 100644 src/Classes/TradeResistanceGrouping.lua create mode 100644 src/Classes/TradeResistanceSwap.lua diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index e11ea701b9b..8673187be9b 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -1,5 +1,6 @@ describe("TradeQueryGenerator", function() local mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) + local tradeResistanceGrouping = LoadModule("Classes/TradeResistanceGrouping") describe("ProcessMod", function() -- Pass: Mod line maps correctly to trade stat entry without error @@ -153,6 +154,306 @@ describe("TradeQueryGenerator", function() end) end) + describe("resistance pseudo-stat grouping", function() + it("derives non-negative cap shortfalls from the blank-item output", function() + assert.are.same({ Fire = 12, Cold = 0, Lightning = 34, Chaos = 56 }, + tradeResistanceGrouping.getResistanceCapShortfall({ + MissingFireResist = 12, + MissingColdResist = -3, + MissingLightningResist = 34, + MissingChaosResist = 56, + })) + end) + + it("annotates weights through the real GenerateModWeights method", function() + local queryGen = new("TradeQueryGenerator", { itemsTab = {} }) + queryGen.modWeights = {} + queryGen.alreadyWeightedMods = {} + queryGen.calcContext = { + itemCategory = "Ring", + testItem = new("Item", "Rarity: RARE\nTest Ring\nCoral Ring\nImplicits: 0"), + baseOutput = { Life = 100 }, + baseStatValue = 1000, + calcFunc = function() return { Life = 110 } end, + options = { + includeTalisman = false, + statWeights = { { stat = "Life", weightMult = 1 } }, + }, + slot = { slotName = "Ring 1" }, + } + queryGen:GenerateModWeights({ + fireResistance = { + Ring = { min = 10, max = 10, subType = "" }, + tradeMod = { id = "explicit.fire_resistance", text = "+#% to Fire Resistance" }, + specialCaseData = {}, + }, + }) + + assert.are.equal(1, #queryGen.modWeights) + assert.is_true(queryGen.modWeights[1].resistTag.elemental) + assert.are.equal(queryGen.modWeights[1].weight, queryGen.modWeights[1].normalisedWeight) + end) + + local function finishQuery(options, weights) + options = options or {} + local queryGen = new("TradeQueryGenerator", { itemsTab = {} }) + queryGen.tradeTypeIndex = 4 + queryGen.modWeights = weights + queryGen.calcContext = { + itemCategoryQueryStr = "accessory.ring", + special = {}, + testItem = new("Item", "Rarity: RARE\nTest Ring\nCoral Ring\nImplicits: 0"), + baseOutput = { Life = 100 }, + baseStatValue = 1000, + calcFunc = function() return { Life = 100 } end, + options = { + includeMirrored = true, + influence1 = 1, + influence2 = 1, + statWeights = { { stat = "Life", weightMult = 1 } }, + groupResists = options.groupResists, + includeResistCaps = options.includeResistCaps, + }, + requiredMods = options.requiredMods or {}, + resistCapShortfall = options.resistCapShortfall, + } + queryGen.requesterContext = { slotTbl = { sentinel = true } } + local queryJson + local queryOptions + local queryError + queryGen.requesterCallback = function(_, json, errMsg, optionsSnapshot) + queryJson = json + queryError = errMsg + queryOptions = optionsSnapshot + end + queryGen:FinishQuery() + return require("dkjson").decode(queryJson), queryGen.requesterContext.slotTbl, queryOptions, queryError + end + + local function annotatedWeight(id, text, weight, meanStatDiff) + return tradeResistanceGrouping.annotateResistanceWeight({ + tradeModId = id, + weight = weight, + meanStatDiff = meanStatDiff, + invert = false, + }, text) + end + + it("groups resistance without changing damage filters", function() + local query = finishQuery({ groupResists = true }, { + annotatedWeight("explicit.fire_resistance", "+#% to Fire Resistance", 10, 10), + { tradeModId = "explicit.fire_damage", weight = 8, meanStatDiff = 8, invert = false }, + { tradeModId = "explicit.life", weight = 6, meanStatDiff = 6, invert = false }, + }) + local ids = {} + for _, filter in ipairs(query.query.stats[1].filters) do + ids[filter.id] = true + end + + assert.is_true(ids["pseudo.pseudo_total_elemental_resistance"]) + assert.is_true(ids["explicit.fire_damage"]) + assert.is_true(ids["explicit.life"]) + assert.is_nil(ids["explicit.fire_resistance"]) + end) + + it("leaves hybrid elemental and chaos resistance as its only original filter", function() + local query = finishQuery({ groupResists = true }, { + annotatedWeight("explicit.hybrid_resistance", "+#% to Fire and Chaos Resistances", 10, 10), + }) + local filters = query.query.stats[1].filters + + assert.are.equal(1, #filters) + assert.are.equal("explicit.hybrid_resistance", filters[1].id) + end) + + it("leaves implicit elemental resistance as its original filter", function() + local query = finishQuery({ groupResists = true }, { + annotatedWeight("implicit.fire_resistance", "+#% to Fire Resistance", 10, 10), + }) + local filters = query.query.stats[1].filters + + assert.are.equal(1, #filters) + assert.are.equal("implicit.fire_resistance", filters[1].id) + end) + + it("does not let hybrid resistance expansion evict a lower-priority filter", function() + local weights = { + annotatedWeight("explicit.hybrid_resistance", "+#% to Fire and Chaos Resistances", 100, 100), + } + for index = 1, 31 do + table.insert(weights, { + tradeModId = string.format("explicit.filler_%d", index), + weight = 100 - index, + meanStatDiff = 100 - index, + invert = false, + }) + end + table.insert(weights, { tradeModId = "explicit.low_priority_filter", weight = 1, meanStatDiff = 1, invert = false }) + + local query = finishQuery({ groupResists = true }, weights) + local ids = {} + for _, filter in ipairs(query.query.stats[1].filters) do + ids[filter.id] = true + end + + assert.are.equal(33, #query.query.stats[1].filters) + assert.is_true(ids["explicit.hybrid_resistance"]) + assert.is_true(ids["explicit.low_priority_filter"]) + end) + + it("does not persist the grouping option into requester context", function() + local _, slotTable, queryOptions = finishQuery({ groupResists = true }, { + { tradeModId = "explicit.life", weight = 6, meanStatDiff = 6, invert = false }, + }) + + assert.are.same({ sentinel = true }, slotTable) + assert.are.same({ groupResists = true, includeResistCaps = false, weightAdjustedSearch = true }, queryOptions) + end) + + it("normalises multi-element resistance weights before pseudo grouping", function() + local query = finishQuery({ groupResists = true }, { + annotatedWeight("explicit.all_resistance", "+#% to all Elemental Resistances", 30, 30), + annotatedWeight("explicit.fire_resistance", "+#% to Fire Resistance", 8, 8), + }) + local filter = query.query.stats[1].filters[1] + + assert.are.equal("pseudo.pseudo_total_elemental_resistance", filter.id) + assert.are.equal(10, filter.value.weight) + end) + + it("moves individual resistance shortfalls into AND filters and removes resistance weights", function() + local query, _, queryOptions = finishQuery({ + includeResistCaps = true, + resistCapShortfall = { Fire = 10, Cold = 20, Lightning = 30, Chaos = 40 }, + }, { + annotatedWeight("explicit.fire_resistance", "+#% to Fire Resistance", 10, 10), + annotatedWeight("implicit.cold_resistance", "+#% to Cold Resistance", 9, 9), + annotatedWeight("explicit.fire_chaos_resistance", "+#% to Fire and Chaos Resistances", 8, 8), + { tradeModId = "explicit.life", weight = 6, meanStatDiff = 6, invert = false }, + }) + local weightedIds = {} + for _, filter in ipairs(query.query.stats[1].filters) do + weightedIds[filter.id] = true + end + assert.are.same({ ["explicit.life"] = true }, weightedIds) + + local minimums = {} + for _, group in ipairs(query.query.stats) do + if group.type == "and" then + for _, filter in ipairs(group.filters) do + minimums[filter.id] = filter.value.min + end + end + end + assert.are.same({ + ["pseudo.pseudo_total_fire_resistance"] = 10, + ["pseudo.pseudo_total_cold_resistance"] = 20, + ["pseudo.pseudo_total_lightning_resistance"] = 30, + ["pseudo.pseudo_total_chaos_resistance"] = 40, + }, minimums) + assert.are.equal(0, query.query.stats[1].value.min) + assert.is_false(queryOptions.weightAdjustedSearch) + end) + + it("combines elemental shortfalls when caps and swaps are enabled", function() + local query = finishQuery({ + groupResists = true, + includeResistCaps = true, + resistCapShortfall = { Fire = 10, Cold = 20, Lightning = 30, Chaos = 40 }, + }, { + annotatedWeight("explicit.fire_resistance", "+#% to Fire Resistance", 10, 10), + { tradeModId = "explicit.life", weight = 6, meanStatDiff = 6, invert = false }, + }) + local minimums = {} + for _, group in ipairs(query.query.stats) do + if group.type == "and" then + for _, filter in ipairs(group.filters) do + minimums[filter.id] = filter.value.min + end + end + end + assert.are.same({ + ["pseudo.pseudo_total_elemental_resistance"] = 60, + ["pseudo.pseudo_total_chaos_resistance"] = 40, + }, minimums) + assert.are.equal(1, #query.query.stats[1].filters) + assert.are.equal("explicit.life", query.query.stats[1].filters[1].id) + end) + + it("builds an AND-only price-sorted query when caps remove every weighted filter", function() + local query, _, queryOptions = finishQuery({ + includeResistCaps = true, + resistCapShortfall = { Fire = 25 }, + }, { + annotatedWeight("explicit.fire_resistance", "+#% to Fire Resistance", 10, 10), + }) + + assert.are.equal(1, #query.query.stats) + assert.are.equal("and", query.query.stats[1].type) + assert.are.same({ price = "asc" }, query.sort) + assert.is_false(queryOptions.weightAdjustedSearch) + end) + + it("does not add zero resistance minimums or an empty AND group", function() + local query, _, _, queryError = finishQuery({ + includeResistCaps = true, + resistCapShortfall = { Fire = 0, Cold = 0, Lightning = 0, Chaos = 0 }, + }, { + annotatedWeight("explicit.fire_resistance", "+#% to Fire Resistance", 10, 10), + }) + + assert.are.equal(0, #query.query.stats) + assert.is_truthy(queryError) + end) + + it("preserves the upstream weighted-group error for required-only searches when caps are off", function() + local query, _, queryOptions, queryError = finishQuery({ + requiredMods = { { tradeId = "explicit.required", value = 10 } }, + }, {}) + + assert.are.equal("weight", query.query.stats[1].type) + assert.are.equal(0, #query.query.stats[1].filters) + assert.are.equal("and", query.query.stats[2].type) + assert.are.same({ ["statgroup.0"] = "desc" }, query.sort) + assert.is_false(queryOptions.weightAdjustedSearch) + assert.is_truthy(queryError) + end) + + it("budgets cap and required filters before weighted filters", function() + local requiredMods = {} + for index = 1, 32 do + requiredMods[index] = { tradeId = "explicit.required_" .. index, value = index } + end + local query, _, queryOptions = finishQuery({ + includeResistCaps = true, + resistCapShortfall = { Fire = 25 }, + requiredMods = requiredMods, + }, { + { tradeModId = "explicit.life", weight = 6, meanStatDiff = 6, invert = false }, + }) + local filterCount = 0 + for _, group in ipairs(query.query.stats) do + filterCount = filterCount + #group.filters + end + + assert.are.equal(34, filterCount) + assert.is_false(queryOptions.weightAdjustedSearch) + end) + + it("preserves upstream filter order when resistance grouping is disabled", function() + local query = finishQuery({ groupResists = false }, { + annotatedWeight("explicit.fire_resistance", "+#% to Fire Resistance", 3, 30), + { tradeModId = "explicit.fire_damage", weight = 2, meanStatDiff = 20, invert = false }, + { tradeModId = "explicit.life", weight = 1, meanStatDiff = 10, invert = false }, + }) + local filters = query.query.stats[1].filters + + assert.are.equal("explicit.fire_resistance", filters[1].id) + assert.are.equal("explicit.fire_damage", filters[2].id) + assert.are.equal("explicit.life", filters[3].id) + end) + end) + describe("Filter prioritization", function() it("counts socket and link constraints against MAX_FILTERS", function() local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = { items = {} } }) diff --git a/spec/System/TestTradeQueryRequests_spec.lua b/spec/System/TestTradeQueryRequests_spec.lua index a872f1ecf9c..5933b93e545 100644 --- a/spec/System/TestTradeQueryRequests_spec.lua +++ b/spec/System/TestTradeQueryRequests_spec.lua @@ -194,6 +194,65 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] end) describe("FetchResultBlock", function() + local function makeExplicitMod(description, domain, hash, name, tier, min, max, flags) + return { + description = description, + domain = domain, + hash = "stat." .. hash, + flags = flags, + mods = { + { + name = name, + tier = tier, + level = 44, + magnitudes = { { min = tostring(min), max = tostring(max) } }, + }, + }, + } + end + + local function makeStandaloneItem(domain) + domain = domain or "explicit" + local hash = domain .. ".fire_resistance" + return { + rarity = "Rare", + name = "Test Subject", + typeLine = "Coral Ring", + explicitMods = { + makeExplicitMod("+17% to Fire Resistance", domain, hash, "of the Salamander", "S7", 12, 17, + domain == "crafted" and { crafted = true } or nil), + }, + extended = { hashes = { [domain] = { { hash, { 0 } } } } }, + } + end + + local function fetchSingle(item) + local response = dkjson.encode({ + result = { + { + id = "item-id", + listing = { + price = { amount = 1, currency = "chaos", type = "~price" }, + whisper = "private listing text", + account = { name = "private account" }, + }, + item = item, + }, + }, + }) + local fetchedItems + local callbackError + requests.requestQueue.fetch = {} + requests:FetchResultBlock("test", function(items, errMsg) + fetchedItems = items + callbackError = errMsg + end) + local request = table.remove(requests.requestQueue.fetch, 1) + request.callback(response) + assert.is_nil(callbackError) + return fetchedItems[1] + end + it("reads weighted sums from current and legacy pseudo mods", function() local function makeTradeEntry(id, pseudoMods) return { @@ -238,6 +297,144 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] assert.are.equal("42", itemsById.legacy.weight) assert.are.equal("0", itemsById.empty.weight) end) + + it("keeps only a compact descriptor for a standalone explicit resistance", function() + local result = fetchSingle(makeStandaloneItem()) + + assert.are.same({ + { + lineIndex = 1, + element = "Fire", + domain = "explicit", + tier = "S7", + range = { min = 12, max = 17 }, + }, + }, result.resistanceSwapDescriptors) + assert.is_nil(result.explicitMods) + assert.is_nil(result.extended) + end) + + it("accepts metadata when the stat hash is nested on the unique mod", function() + local item = makeStandaloneItem() + item.explicitMods[1].mods[1].hash = item.explicitMods[1].hash + item.explicitMods[1].hash = nil + + local result = fetchSingle(item) + assert.are.equal("Fire", result.resistanceSwapDescriptors[1].element) + end) + + it("accepts a resistance whose neighbouring affix has a distinct group", function() + local item = makeStandaloneItem() + table.insert(item.explicitMods, makeExplicitMod( + "11% of Physical Damage from Hits taken as Fire Damage", "explicit", "explicit.phys_taken", + "The Elder's", "P1", 13, 15)) + item.extended.hashes.explicit = { + { "explicit.fire_resistance", { 2 } }, + { "explicit.phys_taken", { 0 } }, + } + + local result = fetchSingle(item) + assert.are.equal(1, #result.resistanceSwapDescriptors) + assert.are.equal(1, result.resistanceSwapDescriptors[1].lineIndex) + local parsedItem = new("Item", result.item_string) + assert.are.equal("+17% to Fire Resistance", parsedItem.explicitModLines[1].line) + assert.are.equal("11% of Physical Damage from Hits taken as Fire Damage", parsedItem.explicitModLines[2].line) + end) + + it("rejects a composite resistance whose lines share one affix group", function() + local item = makeStandaloneItem() + item.explicitMods[1].mods[1].name = "of Puhuarte" + item.explicitMods[1].mods[1].tier = "S0" + table.insert(item.explicitMods, makeExplicitMod( + "3% of Physical Damage from Hits taken as Fire Damage", "explicit", "explicit.phys_taken", + "of Puhuarte", "S0", 3, 5)) + item.extended.hashes.explicit = { + { "explicit.fire_resistance", { 0 } }, + { "explicit.phys_taken", { 0 } }, + } + + local result = fetchSingle(item) + assert.is_nil(result.resistanceSwapDescriptors) + end) + + it("rejects a composite resistance when its sibling line loses its hash mapping", function() + local item = makeStandaloneItem() + item.explicitMods[1].mods[1].name = "of Puhuarte" + item.explicitMods[1].mods[1].tier = "S0" + local sibling = makeExplicitMod( + "3% of Physical Damage from Hits taken as Fire Damage", "explicit", "explicit.phys_taken", + "of Puhuarte", "S0", 3, 5) + sibling.hash = nil + table.insert(item.explicitMods, sibling) + item.extended.hashes.explicit = { + { "explicit.fire_resistance", { 0 } }, + } + + local result = fetchSingle(item) + assert.is_nil(result.resistanceSwapDescriptors) + end) + + it("rejects the whole item when a sibling loses both group and identity metadata", function() + local item = makeStandaloneItem() + local sibling = makeExplicitMod( + "3% of Physical Damage from Hits taken as Fire Damage", "explicit", "explicit.phys_taken", + "of Puhuarte", "S0", 3, 5) + sibling.hash = nil + sibling.mods[1].level = nil + table.insert(item.explicitMods, sibling) + + local result = fetchSingle(item) + assert.is_nil(result.resistanceSwapDescriptors) + end) + + it("keeps explicit and crafted affixes separate when their group indices collide", function() + local item = makeStandaloneItem("crafted") + table.insert(item.explicitMods, 1, makeExplicitMod( + "+25 to maximum Life", "explicit", "explicit.life", "Healthy", "P1", 20, 29)) + item.extended.hashes.explicit = { { "explicit.life", { 0 } } } + local result = fetchSingle(item) + + assert.are.equal("crafted", result.resistanceSwapDescriptors[1].domain) + assert.are.equal(2, result.resistanceSwapDescriptors[1].lineIndex) + assert.is_truthy(result.item_string:find("{crafted}%+17%% to Fire Resistance")) + local parsedItem = new("Item", result.item_string) + assert.are.equal("+17% to Fire Resistance", parsedItem.explicitModLines[2].line) + assert.is_true(parsedItem.explicitModLines[2].crafted) + end) + + it("rejects immutable items and fractured resistance lines", function() + local cases = { + function(item) item.explicitMods[1].flags = { fractured = true } end, + function(item) item.corrupted = true end, + function(item) item.duplicated = true end, + function(item) item.mirrored = true end, + function(item) item.unmodifiable = true end, + function(item) item.unmodifiableExceptChaos = true end, + } + for _, mutate in ipairs(cases) do + local item = makeStandaloneItem() + mutate(item) + assert.is_nil(fetchSingle(item).resistanceSwapDescriptors) + end + end) + + it("falls back safely when resistance metadata is missing or ambiguous", function() + local cases = { + function(item) item.extended = nil end, + function(item) item.explicitMods[1].mods = {} end, + function(item) table.insert(item.explicitMods[1].mods, item.explicitMods[1].mods[1]) end, + function(item) item.explicitMods[1].mods[1].tier = nil end, + function(item) item.explicitMods[1].mods[1].level = nil end, + function(item) item.explicitMods[1].mods[1].magnitudes = {} end, + function(item) item.extended.hashes.explicit[1][2] = { 0, 1 } end, + function(item) table.insert(item.extended.hashes.explicit, { "explicit.fire_resistance", { 0 } }) end, + } + for _, mutate in ipairs(cases) do + local item = makeStandaloneItem() + mutate(item) + assert.is_nil(fetchSingle(item).resistanceSwapDescriptors) + end + end) end) describe("FetchResults", function() diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 9a83a331c4f..3adda2841ce 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -60,6 +60,35 @@ describe("TradeQuery", function() end) assert.are.equal(0, #tooltip.lines) end) + + it("shows the estimated resistance swap without changing the listed item", function() + local itemString = "Rarity: RARE\nBehemoth Hold\nCoral Ring\nImplicits: 0\n+17% to Fire Resistance" + local tq = newTradeQuery({ + resultTbl = { [1] = { [1] = { + item_string = itemString, + amount = 1, + currency = "chaos", + evaluation = { { + output = {}, + weight = 1, + theoreticalResistanceSwap = { { from = "Fire", to = "Cold", value = 17 } }, + } }, + } } }, + sortedResultTbl = { [1] = { { index = 1 } } }, + }) + tq.itemsTab.AddItemTooltip = function() end + local dropdown = buildRow1Dropdown(tq) + local tooltip = new("Tooltip") + + dropdown.tooltipFunc(tooltip, "DROP", 1, nil) + local text = "" + for _, line in ipairs(tooltip.lines) do + text = text .. (line.text or "") .. "\n" + end + assert.is_truthy(text:find("Estimated resistance swap: Fire to Cold %(17%%%)")) + assert.is_truthy(text:find("listed value; Harvest may reroll", 1, true)) + assert.are.equal(itemString, tq.resultTbl[1][1].item_string) + end) end) describe("ReduceOutput", function() it("preserves lower-is-better values for weighted result comparison", function() @@ -114,4 +143,326 @@ describe("TradeQuery", function() assert.are.equals(1.2, result) end) end) + + describe("exact listing query", function() + it("keeps the existing weight range narrowing for weighted queries", function() + local query = require("dkjson").encode({ + query = { stats = { { type = "weight", value = { min = 10 }, filters = {} } }, filters = {} }, + }) + local exact = require("dkjson").decode(mock_tradeQuery:BuildExactListingQuery(query, { + trader = "WeightSeller", + weight = "172", + })) + + assert.are.equal(171, exact.query.stats[1].value.min) + assert.are.equal(173, exact.query.stats[1].value.max) + end) + + it("preserves an AND-only resistance query and adds the trader account", function() + local query = require("dkjson").encode({ + query = { + stats = { { + type = "and", + filters = { { id = "pseudo.pseudo_total_fire_resistance", value = { min = 40 } } }, + } }, + filters = {}, + }, + }) + local exact = require("dkjson").decode(mock_tradeQuery:BuildExactListingQuery(query, { + trader = "CapSeller", + weight = "0", + })) + + assert.are.equal("and", exact.query.stats[1].type) + assert.is_nil(exact.query.stats[1].value) + assert.are.equal(40, exact.query.stats[1].filters[1].value.min) + assert.are.equal("CapSeller", exact.query.filters.trade_filters.filters.account.input) + end) + end) + + describe("generated query routing", function() + it("uses the plain search path for caps and weight adjustment otherwise", function() + local calls = {} + mock_tradeQuery.pbRealm = "pc" + mock_tradeQuery.pbLeague = "Standard" + mock_tradeQuery.tradeQueryRequests = { + SearchWithQuery = function(_, realm, league, query) + table.insert(calls, { "plain", realm, league, query }) + end, + SearchWithQueryWeightAdjusted = function(_, realm, league, query) + table.insert(calls, { "adjusted", realm, league, query }) + end, + } + + mock_tradeQuery:SearchGeneratedQuery({ weightAdjustedSearch = false }, "caps-query", function() end, {}) + mock_tradeQuery:SearchGeneratedQuery({ weightAdjustedSearch = true }, "weighted-query", function() end, {}) + + assert.are.same({ + { "plain", "pc", "Standard", "caps-query" }, + { "adjusted", "pc", "Standard", "weighted-query" }, + }, calls) + end) + end) + + describe("resistance swap result evaluation", function() + local function itemString(lines) + return "Rarity: RARE\nTest Ring\nCoral Ring\nImplicits: 0\n" .. table.concat(lines, "\n") + end + + local function descriptor(lineIndex, element, domain) + return { + lineIndex = lineIndex, + element = element, + domain = domain or "explicit", + tier = "S1", + range = { min = 1, max = 48 }, + } + end + + local function newEvaluationQuery(lines, descriptors, enabled, capsRequired) + local tq = new("TradeQuery", { itemsTab = {} }) + tq.tradeQueryGenerator = mock_queryGen + tq.slotTables[1] = { slotName = "Ring 1" } + tq.statSortSelectionList = { { stat = "Life", weightMult = 1 } } + tq.resultTbl[1] = { { + item_string = itemString(lines), + resistanceSwapDescriptors = descriptors, + resistanceSwapEnabled = enabled, + resistanceCapsRequired = capsRequired, + } } + return tq + end + + local function scoreFromElements(multipliers, onEvaluation) + return function(args) + local score = 0 + local seen = {} + for _, modLine in ipairs(args.repItem.explicitModLines) do + local value, element = modLine.line:match("^%+(%d+)%% to (%a+) Resistance$") + if value and multipliers[element] then + assert.is_nil(seen[element], "duplicate resistance target " .. element) + seen[element] = true + score = score + tonumber(value) * multipliers[element] + end + end + if onEvaluation then + onEvaluation() + end + return { Life = 100 + score } + end + end + + local function scoreAndCapsFromElements(requirements, multipliers) + return function(args) + local totals = { Fire = 0, Cold = 0, Lightning = 0, Chaos = 0 } + local score = 0 + for _, modLine in ipairs(args.repItem.explicitModLines) do + local value, element = modLine.line:match("^%+(%d+)%% to (%a+) Resistance$") + if value and totals[element] then + totals[element] = totals[element] + tonumber(value) + score = score + tonumber(value) * ((multipliers and multipliers[element]) or 0) + end + end + local output = { Life = 100 + score } + for element, total in pairs(totals) do + output["Missing" .. element .. "Resist"] = math.max(0, (requirements[element] or 0) - total) + end + return output + end + end + + it("evaluates exactly 3, 6, and 6 distinct-target assignments for one to three candidates", function() + local cases = { + { + lines = { "+5 to Strength", "{crafted}+10% to Fire Resistance" }, + descriptors = { descriptor(2, "Fire", "crafted") }, + expectedCalls = 3, + }, + { + lines = { "+10% to Fire Resistance", "+20% to Cold Resistance" }, + descriptors = { descriptor(1, "Fire"), descriptor(2, "Cold") }, + expectedCalls = 6, + }, + { + lines = { "+10% to Fire Resistance", "+20% to Cold Resistance", "+30% to Lightning Resistance" }, + descriptors = { descriptor(1, "Fire"), descriptor(2, "Cold"), descriptor(3, "Lightning") }, + expectedCalls = 6, + }, + } + for _, case in ipairs(cases) do + local calls = 0 + local tq = newEvaluationQuery(case.lines, case.descriptors, true) + local evaluation = tq:GetResultEvaluation(1, 1, + scoreFromElements({ Fire = 1, Cold = 2, Lightning = 3 }, function() calls = calls + 1 end), + { Life = 100 }) + + assert.are.equal(case.expectedCalls, calls) + assert.are.equal(1, #evaluation) + end + end) + + it("selects the best permutation and leaves the listed item unchanged", function() + local tq = newEvaluationQuery( + { "+10% to Fire Resistance", "+20% to Cold Resistance" }, + { descriptor(1, "Fire"), descriptor(2, "Cold") }, true) + local original = tq.resultTbl[1][1].item_string + + local evaluation = tq:GetResultEvaluation(1, 1, + scoreFromElements({ Fire = 1, Cold = 2, Lightning = 4 }), { Life = 100 }) + local swaps = evaluation[1].theoreticalResistanceSwap + + assert.are.equal(2, #swaps) + assert.are.same({ from = "Fire", to = "Cold", value = 10 }, swaps[1]) + assert.are.same({ from = "Cold", to = "Lightning", value = 20 }, swaps[2]) + assert.are.equal(original, tq.resultTbl[1][1].item_string) + end) + + it("prefers fewer swaps when theoretical weights tie", function() + local tq = newEvaluationQuery( + { "+10% to Cold Resistance" }, { descriptor(1, "Cold") }, true) + + local evaluation = tq:GetResultEvaluation(1, 1, function() + return { Life = 100 } + end, { Life = 100 }) + + assert.is_nil(evaluation[1].theoreticalResistanceSwap) + end) + + it("uses one baseline calculation when reranking is disabled or ineligible", function() + local cases = { + newEvaluationQuery({ "+10% to Fire Resistance" }, { descriptor(1, "Fire") }, false), + newEvaluationQuery({ "+10% to Fire Resistance" }, { descriptor(1, "Cold") }, true), + newEvaluationQuery({ "+10% to Fire Resistance" }, nil, true), + } + for _, tq in ipairs(cases) do + local calls = 0 + tq:GetResultEvaluation(1, 1, function() + calls = calls + 1 + return { Life = 100 } + end, { Life = 100 }) + assert.are.equal(1, calls) + end + end) + + it("keeps only swap permutations that actually reach every resistance cap", function() + local tq = newEvaluationQuery( + { "+40% to Fire Resistance", "+80% to Cold Resistance", "+30% to Chaos Resistance" }, + { descriptor(1, "Fire"), descriptor(2, "Cold") }, true, true) + local evaluation = tq:GetResultEvaluation(1, 1, scoreAndCapsFromElements( + { Fire = 40, Cold = 40, Lightning = 0, Chaos = 30 }, + { Fire = 1, Cold = 1, Lightning = 100 }), { Life = 100 }) + + assert.are.equal(1, #evaluation) + assert.is_nil(evaluation[1].theoreticalResistanceSwap) + end) + + it("rejects an elemental total that cannot be split across the missing caps", function() + local tq = newEvaluationQuery( + { "+80% to Fire Resistance", "+30% to Chaos Resistance" }, + { descriptor(1, "Fire") }, true, true) + local evaluation = tq:GetResultEvaluation(1, 1, scoreAndCapsFromElements( + { Fire = 40, Cold = 40, Lightning = 0, Chaos = 30 }), { Life = 100 }) + + assert.are.equal(0, #evaluation) + end) + + it("validates caps without simulating swaps when only resistance caps are enabled", function() + local valid = newEvaluationQuery( + { "+40% to Fire Resistance", "+30% to Chaos Resistance" }, nil, false, true) + local invalid = newEvaluationQuery( + { "+39% to Fire Resistance", "+30% to Chaos Resistance" }, nil, false, true) + local calc = scoreAndCapsFromElements({ Fire = 40, Cold = 0, Lightning = 0, Chaos = 30 }) + + assert.are.equal(1, #valid:GetResultEvaluation(1, 1, calc, { Life = 100 })) + assert.are.equal(0, #invalid:GetResultEvaluation(1, 1, calc, { Life = 100 })) + end) + + it("rejects an item that only misses the required Chaos resistance", function() + local tq = newEvaluationQuery( + { "+40% to Fire Resistance", "+29% to Chaos Resistance" }, nil, false, true) + local evaluation = tq:GetResultEvaluation(1, 1, + scoreAndCapsFromElements({ Fire = 40, Cold = 0, Lightning = 0, Chaos = 30 }), { Life = 100 }) + + assert.are.equal(0, #evaluation) + end) + + it("removes uncapped results before any result sort is applied", function() + local tq = new("TradeQuery", { itemsTab = {} }) + tq.resultTbl[1] = { + { id = "uncapped", resistanceCapsRequired = true }, + { id = "capped", resistanceCapsRequired = true }, + { id = "unrestricted" }, + } + tq.GetResultEvaluation = function(_, _, resultIndex) + return resultIndex == 1 and {} or { { weight = 1 } } + end + + tq:FilterToResistanceCapItems(1) + + assert.are.same({ "capped", "unrestricted" }, { + tq.resultTbl[1][1].id, + tq.resultTbl[1][2].id, + }) + end) + + it("revalidates and restores fetched results when the build resistance state changes", function() + local requiredFire = 50 + local tq = newEvaluationQuery( + { "+40% to Fire Resistance", "+30% to Chaos Resistance" }, nil, false, true) + local itemEntry = tq.resultTbl[1][1] + tq.unfilteredResultTbl[1] = { itemEntry } + local function calc(args) + return scoreAndCapsFromElements({ + Fire = requiredFire, + Cold = 0, + Lightning = 0, + Chaos = 30, + })(args) + end + tq.itemsTab.build = { calcsTab = { + GetMiscCalculator = function() + return calc, { + Life = 100, + FireResist = 75, + FireResistTotal = requiredFire, + MissingFireResist = 0, + ColdResist = 75, + ColdResistTotal = 75, + MissingColdResist = 0, + LightningResist = 75, + LightningResistTotal = 75, + MissingLightningResist = 0, + ChaosResist = 75, + ChaosResistTotal = 75, + MissingChaosResist = 0, + } + end, + } } + + tq:FilterToResistanceCapItems(1) + assert.are.equal(0, #tq.resultTbl[1]) + + requiredFire = 40 + tq:FilterToResistanceCapItems(1) + assert.are.equal(1, #tq.resultTbl[1]) + end) + + it("reuses the single best evaluation while the build and weights are unchanged", function() + local calls = 0 + local tq = newEvaluationQuery({ "+10% to Fire Resistance" }, { descriptor(1, "Fire") }, true) + local calc = scoreFromElements({ Fire = 1, Cold = 2, Lightning = 3 }, function() calls = calls + 1 end) + tq.itemsTab.build = { calcsTab = { + GetMiscCalculator = function() + return calc, { Life = 100 } + end, + } } + + local first = tq:GetResultEvaluation(1, 1) + local second = tq:GetResultEvaluation(1, 1) + + assert.are.equal(3, calls) + assert.are.equal(first, second) + assert.are.equal(1, #second) + end) + end) end) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 7ea2fcedf00..834d610e84e 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -7,6 +7,7 @@ local dkjson = require "dkjson" local itemSlotHelper = LoadModule("Modules/ItemSlotHelper") +local tradeResistanceSwap = LoadModule("Classes/TradeResistanceSwap") local get_time = os.time local t_insert = table.insert @@ -19,6 +20,27 @@ local s_format = string.format local baseSlots = { "Weapon 1", "Weapon 2", "Weapon 1 Swap", "Weapon 2 Swap", "Helmet", "Body Armour", "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Ring 3", "Belt", "Flask 1", "Flask 2", "Flask 3", "Flask 4", "Flask 5" } +local function meetsResistanceCaps(output) + for _, resistanceType in ipairs({ "Fire", "Cold", "Lightning", "Chaos" }) do + local missing = output["Missing" .. resistanceType .. "Resist"] + if type(missing) ~= "number" or missing > 0 then + return false + end + end + return true +end + +local function getResistanceState(output) + local state = {} + for _, resistanceType in ipairs({ "Fire", "Cold", "Lightning", "Chaos" }) do + for _, suffix in ipairs({ "Resist", "ResistTotal", "Missing" .. resistanceType .. "Resist" }) do + local key = suffix:find("Missing", 1, true) and suffix or resistanceType .. suffix + state[key] = output[key] + end + end + return state +end + ---@class TradeQuery local TradeQueryClass = newClass("TradeQuery") @@ -29,10 +51,12 @@ function TradeQueryClass:TradeQuery(itemsTab) self.controls = { } -- table of price results index by slot and number of fetched results self.resultTbl = { } + self.unfilteredResultTbl = { } self.sortedResultTbl = { } self.itemIndexTbl = { } -- tooltip acceleration tables self.onlyWeightedBaseOutput = { } + self.resistanceBaseOutput = { } self.lastComparedWeightList = { } -- default set of trade item sort selection @@ -807,12 +831,19 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba if not self.lastComparedWeightList[row_idx] then self.lastComparedWeightList[row_idx] = { } end + if not self.resistanceBaseOutput[row_idx] then + self.resistanceBaseOutput[row_idx] = { } + end + local resistanceBaseOutput = result.resistanceCapsRequired and getResistanceState(baseOutput) -- 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 + if result.evaluation and tableDeepEquals(onlyWeightedBaseOutput, self.onlyWeightedBaseOutput[row_idx][result_index]) + and tableDeepEquals(self.statSortSelectionList, self.lastComparedWeightList[row_idx][result_index]) + and (not result.resistanceCapsRequired or tableDeepEquals(resistanceBaseOutput, self.resistanceBaseOutput[row_idx][result_index])) then return result.evaluation end self.onlyWeightedBaseOutput[row_idx][result_index] = onlyWeightedBaseOutput self.lastComparedWeightList[row_idx][result_index] = self.statSortSelectionList + self.resistanceBaseOutput[row_idx][result_index] = resistanceBaseOutput end local slotTbl = self.slotTables[row_idx] local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId @@ -840,10 +871,49 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba table.sort(result.evaluation, function(a, b) return a.weight > b.weight end) else local item = new("Item"):Item(result.item_string) - - local output = self:ReduceOutput(calcFunc({ repSlotName = slotName, repItem = item })) - local weight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, output, self.statSortSelectionList) - result.evaluation = {{ output = output, weight = weight }} + local descriptors = result.resistanceSwapEnabled and result.resistanceSwapDescriptors + local assignments = descriptors and tradeResistanceSwap.validateItem(item, descriptors) + and tradeResistanceSwap.getAssignments(descriptors) or {} + local bestEvaluation + local bestSwapCount + local function evaluateVariant(variant) + local fullOutput = calcFunc({ repSlotName = slotName, repItem = variant }) + if result.resistanceCapsRequired and not meetsResistanceCaps(fullOutput) then + return + end + local output = self:ReduceOutput(fullOutput) + return { + output = output, + weight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, output, self.statSortSelectionList), + } + end + for _, assignment in ipairs(assignments) do + local variant + local swaps + if assignment.swaps == 0 then + variant = item + swaps = {} + else + variant, swaps = tradeResistanceSwap.buildVariant(result.item_string, descriptors, assignment) + end + if variant then + local evaluation = evaluateVariant(variant) + if evaluation and (not bestEvaluation or evaluation.weight > bestEvaluation.weight + or evaluation.weight == bestEvaluation.weight and assignment.swaps < bestSwapCount) then + bestEvaluation = evaluation + bestSwapCount = assignment.swaps + if assignment.swaps > 0 then + bestEvaluation.theoreticalResistanceSwap = swaps + end + end + end + end + if bestEvaluation then + result.evaluation = { bestEvaluation } + else + local evaluation = #assignments == 0 and evaluateVariant(item) + result.evaluation = evaluation and { evaluation } or {} + end end return result.evaluation end @@ -869,11 +939,18 @@ function TradeQueryClass:ResetResultRow(rowIdx) self.itemIndexTbl[rowIdx] = nil self.sortedResultTbl[rowIdx] = nil self.resultTbl[rowIdx] = nil + self.unfilteredResultTbl[rowIdx] = nil + self.onlyWeightedBaseOutput[rowIdx] = nil + self.resistanceBaseOutput[rowIdx] = nil + self.lastComparedWeightList[rowIdx] = nil self.totalPrice[rowIdx] = nil self:UpdateDropdownList(rowIdx) self.controls.fullPrice.label = "^7Total Price: " .. self:GetTotalPriceString() end function TradeQueryClass:UpdateControlsWithItems(row_idx) + if self.unfilteredResultTbl[row_idx] then + self:FilterToResistanceCapItems(row_idx) + end local sortMode = self.itemSortSelectionList[self.pbItemSortSelectionIndex] local sortedItems, errMsg = self:SortFetchResults(row_idx, sortMode) if errMsg == "MissingConversionRates" then @@ -1004,6 +1081,39 @@ function TradeQueryClass:FilterToSafeItems(itemEntries, slotName) end return itemsSafe end + +function TradeQueryClass:FilterToResistanceCapItems(row_idx) + self.resultTbl[row_idx] = self.unfilteredResultTbl[row_idx] or self.resultTbl[row_idx] or {} + local cappedItems = {} + for resultIndex, itemEntry in ipairs(self.resultTbl[row_idx]) do + if not itemEntry.resistanceCapsRequired or #self:GetResultEvaluation(row_idx, resultIndex) > 0 then + t_insert(cappedItems, itemEntry) + end + end + self.resultTbl[row_idx] = cappedItems +end + +function TradeQueryClass:SearchGeneratedQuery(queryOptions, query, callback, params) + local searchMethod = queryOptions and queryOptions.weightAdjustedSearch == false + and self.tradeQueryRequests.SearchWithQuery or self.tradeQueryRequests.SearchWithQueryWeightAdjusted + return searchMethod(self.tradeQueryRequests, self.pbRealm, self.pbLeague, query, callback, params) +end + +function TradeQueryClass:BuildExactListingQuery(query, itemResult) + local exactQuery = dkjson.decode(query) + local firstStatGroup = exactQuery.query.stats and exactQuery.query.stats[1] + if firstStatGroup and firstStatGroup.type == "weight" then + -- Weight on site uses floats but only shows integers in the API. + firstStatGroup.value = { min = floor(itemResult.weight, 1) - 1, max = round(itemResult.weight, 1) + 1 } + end + -- The trader account narrows non-weighted searches and makes weighted false positives extremely unlikely. + exactQuery.query.filters = exactQuery.query.filters or { } + exactQuery.query.filters.trade_filters = exactQuery.query.filters.trade_filters or { filters = { } } + exactQuery.query.filters.trade_filters.filters = exactQuery.query.filters.trade_filters.filters or { } + exactQuery.query.filters.trade_filters.filters.account = { input = itemResult.trader } + return dkjson.encode(exactQuery) +end + -- Method to generate pane elements for each item slot function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, row_vertical_padding, row_height) local controls = self.controls @@ -1021,7 +1131,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.tradeQueryGenerator:RequestQuery(activeSlot, { slotTbl = slotTbl, controls = controls, row_idx = row_idx }, self.statSortSelectionList, function(context, query, errMsg) + self.tradeQueryGenerator:RequestQuery(activeSlot, { slotTbl = slotTbl, controls = controls, row_idx = row_idx }, self.statSortSelectionList, function(context, query, errMsg, queryOptions) if errMsg then self:SetNotice(context.controls.pbNotice, colorCodes.NEGATIVE .. errMsg) return @@ -1036,7 +1146,7 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro end context.controls["priceButton"..context.row_idx].label = "Searching..." self.lastQueries[row_idx] = query - self.tradeQueryRequests:SearchWithQueryWeightAdjusted(self.pbRealm, self.pbLeague, query, + self:SearchGeneratedQuery(queryOptions, query, function(items, errMsg) if errMsg then self:SetNotice(context.controls.pbNotice, colorCodes.NEGATIVE .. errMsg) @@ -1065,8 +1175,11 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro item.enchantModLines = {} end itemsSafe[i].item_string = item:BuildRaw() + itemsSafe[i].resistanceSwapEnabled = queryOptions and queryOptions.groupResists == true + itemsSafe[i].resistanceCapsRequired = queryOptions and queryOptions.includeResistCaps == true end + self.unfilteredResultTbl[context.row_idx] = queryOptions and queryOptions.includeResistCaps and itemsSafe or nil self.resultTbl[context.row_idx] = itemsSafe self:UpdateControlsWithItems(context.row_idx) context.controls["priceButton"..context.row_idx].label = "Price Item" @@ -1082,7 +1195,7 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro end) controls["bestButton"..row_idx].shown = function() return not self.resultTbl[row_idx] end controls["bestButton"..row_idx].enabled = function() return self.pbLeague end - controls["bestButton"..row_idx].tooltipText = [[Creates a weighted search to find the highest Stat Value items for this slot. + controls["bestButton"..row_idx].tooltipText = [[Creates a trade search to find high Stat Value items for this slot. Note that even if you are authenticated, you can click this button again to show the search link. If you have additional requirements that the trade tool doesn't cover (e.g. Adorned Magic jewels), you can add them, copy the link here, and press "Price Item" to evaluate the items.]] @@ -1191,6 +1304,20 @@ 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 addResistanceSwapToTooltipIfApplicable(tooltip, result) + local evaluation = result.evaluation and result.evaluation[1] + local swaps = evaluation and evaluation.theoreticalResistanceSwap + if not swaps or #swaps == 0 then + return + end + local descriptions = {} + for _, swap in ipairs(swaps) do + table.insert(descriptions, string.format("%s to %s (%g%%)", swap.from, swap.to, swap.value)) + end + tooltip:AddSeparator(10) + tooltip:AddLine(16, "^7Estimated resistance swap: " .. table.concat(descriptions, ", ")) + tooltip:AddLine(16, "^8Uses the listed value; Harvest may reroll.") + 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 @@ -1206,6 +1333,7 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite local tooltipSlot = slotTbl.selectedJewelNodeId and self.itemsTab.sockets[slotTbl.selectedJewelNodeId] or activeSlot self.itemsTab:AddItemTooltip(tooltip, item, tooltipSlot) addMegalomaniacCompareToTooltipIfApplicable(tooltip, pb_index) + addResistanceSwapToTooltipIfApplicable(tooltip, result) tooltip:AddSeparator(10) tooltip:AddLine(16, string.format("^7Price: %s %s", result.amount, result.currency)) end @@ -1260,19 +1388,7 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite if itemResult.whisper and (itemResult.priceType ~= "~b/o") then Copy(itemResult.whisper) else - local exactQuery = dkjson.decode(self.lastQueries[row_idx]) - -- use trade sum to get the specific item. both min and max - -- weight on site uses floats but only shows integer in the api - -- e.g. weight of 172.3 shows up as 172 in the api - exactQuery.query.stats[1].value = { min = floor(itemResult.weight, 1) - 1, max = round(itemResult.weight, 1) + 1 } - -- also apply trader name. this should make false positives - -- extremely unlikely. this doesn't seem to take up a filter slot - exactQuery.query.filters = exactQuery.query.filters or { } - exactQuery.query.filters.trade_filters = exactQuery.query.filters.trade_filters or { filters = { } } - exactQuery.query.filters.trade_filters.filters = exactQuery.query.filters.trade_filters.filters or { } - exactQuery.query.filters.trade_filters.filters.account = { input = itemResult.trader } - - local exactQueryStr = dkjson.encode(exactQuery) + local exactQueryStr = self:BuildExactListingQuery(self.lastQueries[row_idx], itemResult) local encodedUrl = s_format("https://www.pathofexile.com/trade/search/%s?q=%s", self.pbLeague, urlEncode(exactQueryStr)) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 9b7e3f5b406..91d719c9f74 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -9,9 +9,18 @@ local curl = require("lcurl.safe") local m_max = math.max local s_format = string.format local t_insert = table.insert +local tradeResistanceGrouping = LoadModule("Classes/TradeResistanceGrouping") local tradeHelpers = LoadModule("Classes/TradeHelpers") local utils = LoadModule("Modules/Utils") +local resistanceTypes = { "Fire", "Cold", "Lightning", "Chaos" } +local resistancePseudoIds = { + Fire = "pseudo.pseudo_total_fire_resistance", + Cold = "pseudo.pseudo_total_cold_resistance", + Lightning = "pseudo.pseudo_total_lightning_resistance", + Chaos = "pseudo.pseudo_total_chaos_resistance", +} + -- a table which tells us what subtypes each category we can search for -- contains. the commented out lines are type-subtype combinations which don't -- exist yet, but might exist in the future @@ -573,7 +582,8 @@ function TradeQueryGeneratorClass:GenerateModWeights(modsToTest) local output = self.calcContext.calcFunc({ repSlotName = self.calcContext.slot.slotName, repItem = self.calcContext.testItem }) local meanStatDiff = TradeQueryGeneratorClass.WeightedRatioOutputs(self.calcContext.baseOutput, output, self.calcContext.options.statWeights) * 1000 - (self.calcContext.baseStatValue or 0) if meanStatDiff > 0.01 then - t_insert(self.modWeights, { tradeModId = entry.tradeMod.id, weight = meanStatDiff / modValue, meanStatDiff = meanStatDiff, invert = entry.sign == "-" and true or false }) + local weightEntry = { tradeModId = entry.tradeMod.id, weight = meanStatDiff / modValue, meanStatDiff = meanStatDiff, invert = entry.sign == "-" and true or false } + t_insert(self.modWeights, tradeResistanceGrouping.annotateResistanceWeight(weightEntry, entry.tradeMod.text)) end self.alreadyWeightedMods[entry.tradeMod.id] = true @@ -740,6 +750,8 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) -- Calculate base output with a blank item local calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() local baseItemOutput = slot and calcFunc({ repSlotName = slot.slotName, repItem = testItem }) or baseOutput + local resistCapShortfall = tradeResistanceGrouping.getResistanceCapShortfall( + slot and not slot.slotName:find("Flask") and baseItemOutput or {}) -- make weights more human readable local compStatValue = TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, baseItemOutput, options.statWeights) * 1000 @@ -758,6 +770,7 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) options = options, slot = slot, requiredMods = options.requiredMods, + resistCapShortfall = resistCapShortfall, } -- OnFrame will pick this up and begin the work @@ -878,6 +891,7 @@ function TradeQueryGeneratorClass:FinishQuery() if self.calcContext.options.includeAllWEMods then self:addMoreWEMods() end + self.modWeights = tradeResistanceGrouping.groupResistanceWeights(self.modWeights, self.calcContext.options.groupResists, self.calcContext.options.includeResistCaps) -- Sort by mean Stat diff rather than weight to more accurately prioritize stats that can contribute more table.sort(self.modWeights, function(a, b) @@ -891,7 +905,7 @@ function TradeQueryGeneratorClass:FinishQuery() local megalomaniacSpecialMinWeight = self.calcContext.special.itemName == "Megalomaniac" and self.modWeights[#self.modWeights] * 3 -- This Stat diff value will generally be higher than the weighted sum of the same item, because the stats are all applied at once and can thus multiply off each other. -- So apply a modifier to get a reasonable min and hopefully approximate that the query will start out with small upgrades. - local minWeight = megalomaniacSpecialMinWeight or currentStatDiff * 0.5 + local minWeight = self.calcContext.options.includeResistCaps and 0 or megalomaniacSpecialMinWeight or currentStatDiff * 0.5 -- what the trade site API uses for instant buyout etc. self.tradeTypes = { @@ -903,8 +917,8 @@ function TradeQueryGeneratorClass:FinishQuery() } local selectedTradeType = self.tradeTypes[self.tradeTypeIndex] -- Generate trade query str and open in browser - local filters = 0 local requiredMods = self.calcContext.requiredMods or {} + local filters = self.calcContext.options.includeResistCaps and #requiredMods or 0 local queryTable = { query = { filters = self.calcContext.special.queryFilters or { @@ -1016,7 +1030,6 @@ function TradeQueryGeneratorClass:FinishQuery() ::weightContinue:: end - for k, v in pairs(self.calcContext.special.queryExtra or {}) do queryTable.query[k] = v end @@ -1031,17 +1044,35 @@ function TradeQueryGeneratorClass:FinishQuery() t_insert(andFilters.filters, { id = hasInfluenceModIds[options.influence2 - 1] }) filters = filters + 1 end + if options.includeResistCaps then + local shortfall = self.calcContext.resistCapShortfall or {} + local function addResistanceMinimum(id, minimum) + if minimum and minimum > 0 then + t_insert(andFilters.filters, { id = id, value = { min = minimum } }) + filters = filters + 1 + end + end + if options.groupResists then + local elementalMinimum = (shortfall.Fire or 0) + (shortfall.Cold or 0) + (shortfall.Lightning or 0) + addResistanceMinimum("pseudo.pseudo_total_elemental_resistance", elementalMinimum) + addResistanceMinimum(resistancePseudoIds.Chaos, shortfall.Chaos) + else + for _, resistanceType in ipairs(resistanceTypes) do + addResistanceMinimum(resistancePseudoIds[resistanceType], shortfall[resistanceType]) + end + end + end if #andFilters.filters > 0 then t_insert(queryTable.query.stats, andFilters) end - + for _, entry in ipairs(statFilters) do - t_insert(queryTable.query.stats[1].filters, entry) - filters = filters + 1 - if filters == effective_max then + if filters >= effective_max then break end + t_insert(queryTable.query.stats[1].filters, entry) + filters = filters + 1 end for _, entry in ipairs(requiredMods) do t_insert(requiredModFilters.filters, { id = entry.tradeId, value = { min = entry.value } }) @@ -1112,14 +1143,24 @@ function TradeQueryGeneratorClass:FinishQuery() end end + local hasWeightedFilters = #queryTable.query.stats[1].filters > 0 + if not hasWeightedFilters and options.includeResistCaps then + table.remove(queryTable.query.stats, 1) + queryTable.sort = { price = "asc" } + end + local errMsg = nil - if #queryTable.query.stats[1].filters == 0 then + if not hasWeightedFilters and (not options.includeResistCaps or #queryTable.query.stats == 0) then -- No mods to filter errMsg = "Could not generate search, found no mods to search for" end local queryJson = dkjson.encode(queryTable) - self.requesterCallback(self.requesterContext, queryJson, errMsg) + self.requesterCallback(self.requesterContext, queryJson, errMsg, { + groupResists = options.groupResists == true, + includeResistCaps = options.includeResistCaps == true, + weightAdjustedSearch = hasWeightedFilters and not options.includeResistCaps, + }) -- Close blocker popup main:ClosePopup() @@ -1293,6 +1334,18 @@ Remove: %s will be removed from the search results.]], term, term, term) controls.maxLevelLabel = new("LabelControl"):LabelControl({ "RIGHT", controls.maxLevel, "LEFT" }, { -5, 0, 0, 16 }, "^7Max Level:") updateLastAnchor(controls.maxLevel) + if not context.slotTbl.unique then + controls.groupResists = new("CheckBoxControl", { "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 18 }, "Resistance swaps:", function(state) end) + controls.groupResists.state = self.lastGroupResists == true + controls.groupResists.tooltipText = "Searches total resistance and estimates Fire/Cold/Lightning swaps for Stat Value.\nHarvest may reroll values." + updateLastAnchor(controls.groupResists) + + controls.includeResistCaps = new("CheckBoxControl", { "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 18 }, "Resistance caps:", function(state) end) + controls.includeResistCaps.state = self.lastIncludeResistCaps == true + controls.includeResistCaps.tooltipText = "Requires listed resistance to reach current elemental and Chaos caps; extra resistance is not weighted.\nWith swaps, filters by total resistance first; fetched items need a valid estimate." + updateLastAnchor(controls.includeResistCaps) + end + -- basic filtering by slot for sockets and links, Megalomaniac does not have slot and Sockets use "Jewel nodeId" if slot and not isJewelSlot and not isAbyssalJewelSlot and not slot.slotName:find("Flask") then controls.sockets = new("EditControl"):EditControl({"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 70, 18}, nil, nil, "%D") @@ -1398,6 +1451,14 @@ Remove: %s will be removed from the search results.]], term, term, term) if #selectedMods > 0 then options.requiredMods = copyTable(selectedMods) end + if controls.groupResists then + self.lastGroupResists = controls.groupResists.state + options.groupResists = controls.groupResists.state + end + if controls.includeResistCaps then + self.lastIncludeResistCaps = controls.includeResistCaps.state + options.includeResistCaps = controls.includeResistCaps.state + end options.statWeights = statWeights if controls.jewelSlot then slot = controls.jewelSlot:GetSelValue() diff --git a/src/Classes/TradeQueryRequests.lua b/src/Classes/TradeQueryRequests.lua index b0067123807..850d31e81f9 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -5,6 +5,7 @@ -- local dkjson = require "dkjson" +local tradeResistanceSwap = LoadModule("Classes/TradeResistanceSwap") local utils = LoadModule("Modules/Utils") ---@class TradeQueryRequests @@ -294,6 +295,7 @@ function TradeQueryRequestsClass:FetchResultBlock(url, callback) for _, trade_entry in pairs(response.result) do local item = trade_entry.item local t_insert = table.insert + local resistanceSwapDescriptors = tradeResistanceSwap.extractDescriptors(item) local rawLines = {} t_insert(rawLines, "Rarity: " .. item.rarity) @@ -344,6 +346,9 @@ function TradeQueryRequestsClass:FetchResultBlock(url, callback) 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 return s .. escapeGGGString(modLine.description) end t_insert(rawLines, "Implicits: " .. (#item.enchantMods + #item.scourgeMods + #item.implicitMods)) @@ -367,7 +372,7 @@ function TradeQueryRequestsClass:FetchResultBlock(url, callback) end local pseudoMod = trade_entry.item.pseudoMods and trade_entry.item.pseudoMods[1] local pseudoModLine = pseudoMod and (pseudoMod.description or pseudoMod) - table.insert(items, { + local resultItem = { amount = trade_entry.listing.price.amount, currency = trade_entry.listing.price.currency, priceType = trade_entry.listing.price.type, @@ -376,7 +381,11 @@ function TradeQueryRequestsClass:FetchResultBlock(url, callback) trader = trade_entry.listing.account.name, weight = pseudoModLine and pseudoModLine:match("Sum: (.+)") or "0", id = trade_entry.id - }) + } + if #resistanceSwapDescriptors > 0 then + resultItem.resistanceSwapDescriptors = resistanceSwapDescriptors + end + table.insert(items, resultItem) end return callback(items) end diff --git a/src/Classes/TradeResistanceGrouping.lua b/src/Classes/TradeResistanceGrouping.lua new file mode 100644 index 00000000000..6ec0a3c77ff --- /dev/null +++ b/src/Classes/TradeResistanceGrouping.lua @@ -0,0 +1,112 @@ +-- Path of Building +-- +-- Module: Trade Resistance Grouping +-- Stateless classification and grouping helpers for resistance trade query weights. +-- + +local M = {} + +local resistanceTypes = { "Fire", "Cold", "Lightning", "Chaos" } +local elementSet = { + Fire = true, + Cold = true, + Lightning = true, +} + +function M.getResistanceCapShortfall(output) + local shortfall = {} + for _, resistanceType in ipairs(resistanceTypes) do + shortfall[resistanceType] = math.max(0, output["Missing" .. resistanceType .. "Resist"] or 0) + end + return shortfall +end + +local function isElement(element) + return elementSet[element] == true +end + +local function maxField(current, entry, field) + local value = entry[field] or 0 + return value > current and value or current +end + +function M.classifyResistanceMod(modText) + local resistanceElement = modText:match("^%+#%% to (%a+) Resistance$") + if isElement(resistanceElement) then + return { resistTag = { elemental = true }, normalisationFactor = 1, group = "elemental" } + elseif resistanceElement == "Chaos" then + return { resistTag = { chaos = true }, normalisationFactor = 1, group = "chaos" } + end + + if modText == "+#% to all Elemental Resistances" then + return { resistTag = { elemental = true }, normalisationFactor = 3, group = "elemental" } + end + local firstElement, secondElement = modText:match("^%+#%% to (%a+) and (%a+) Resistances$") + if isElement(firstElement) and isElement(secondElement) then + return { resistTag = { elemental = true }, normalisationFactor = 2, group = "elemental" } + elseif isElement(firstElement) and secondElement == "Chaos" then + return { resistTag = { elemental = true, chaos = true } } + end +end + +function M.annotateResistanceWeight(weightEntry, modText) + if type(weightEntry.tradeModId) ~= "string" then + return weightEntry + end + local classification = M.classifyResistanceMod(modText) + if classification then + weightEntry.resistTag = classification.resistTag + if weightEntry.tradeModId:match("^explicit%.") and classification.group then + weightEntry.resistanceGroup = classification.group + weightEntry.normalisedWeight = weightEntry.weight / classification.normalisationFactor + end + end + return weightEntry +end + +local function makePseudoWeight(id, aggregate) + return { + tradeModId = id, + weight = aggregate.weight, + meanStatDiff = aggregate.meanStatDiff, + invert = false, + } +end + +function M.groupResistanceWeights(modWeights, groupResists, includeResistCaps) + if not groupResists and not includeResistCaps then + return modWeights + end + + local kept = {} + local elementalResistance = { weight = 0, meanStatDiff = 0 } + local chaosResistance = { weight = 0, meanStatDiff = 0 } + for _, entry in ipairs(modWeights) do + if entry.resistTag then + local normalisedWeight = entry.normalisedWeight or entry.weight + if not includeResistCaps and entry.resistanceGroup == "elemental" then + elementalResistance.weight = math.max(elementalResistance.weight, normalisedWeight) + elementalResistance.meanStatDiff = maxField(elementalResistance.meanStatDiff, entry, "meanStatDiff") + end + if not includeResistCaps and entry.resistanceGroup == "chaos" then + chaosResistance.weight = math.max(chaosResistance.weight, normalisedWeight) + chaosResistance.meanStatDiff = maxField(chaosResistance.meanStatDiff, entry, "meanStatDiff") + end + if not includeResistCaps and not entry.resistanceGroup then + table.insert(kept, entry) + end + else + table.insert(kept, entry) + end + end + + if elementalResistance.weight > 0 then + table.insert(kept, makePseudoWeight("pseudo.pseudo_total_elemental_resistance", elementalResistance)) + end + if chaosResistance.weight > 0 then + table.insert(kept, makePseudoWeight("pseudo.pseudo_total_chaos_resistance", chaosResistance)) + end + return kept +end + +return M diff --git a/src/Classes/TradeResistanceSwap.lua b/src/Classes/TradeResistanceSwap.lua new file mode 100644 index 00000000000..69507021407 --- /dev/null +++ b/src/Classes/TradeResistanceSwap.lua @@ -0,0 +1,223 @@ +-- Path of Building +-- +-- Module: Trade Resistance Swap +-- Extracts safe resistance-swap metadata and builds theoretical item variants. +-- + +local M = {} + +local elements = { "Fire", "Cold", "Lightning" } +local elementSet = { Fire = true, Cold = true, Lightning = true } + +local function groupKey(domain, index) + return domain .. ":" .. tostring(index) +end + +local function getHashGroups(item) + local groupsByDomain = {} + local hashes = item.extended and item.extended.hashes or {} + for _, domain in ipairs({ "explicit", "crafted" }) do + local groupsByHash = {} + for _, entry in ipairs(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 + return groupsByDomain +end + +local function getUniqueMod(modLine) + local metadata = type(modLine.mods) == "table" and modLine.mods + return metadata and #metadata == 1 and metadata[1] +end + +local function getAffixFingerprint(modLine) + local domain = modLine.domain + local mod = getUniqueMod(modLine) + if (domain ~= "explicit" and domain ~= "crafted") or not mod + or type(mod.name) ~= "string" or mod.name == "" + or type(mod.tier) ~= "string" or mod.tier == "" + or type(mod.level) ~= "number" then + return + end + return table.concat({ domain, mod.name, mod.tier, tostring(mod.level) }, "\0") +end + +local function getLineGroups(modLine, groupsByDomain) + local domain = modLine.domain + local metadata = getUniqueMod(modLine) + local magnitude = metadata and type(metadata.magnitudes) == "table" and metadata.magnitudes[1] + local rawHash = modLine.hash or metadata and metadata.hash or magnitude and magnitude.hash + local hash = type(rawHash) == "string" and rawHash:gsub("^stat%.", "") + return groupsByDomain[domain] and groupsByDomain[domain][hash] +end + +-- Extract only the compact, non-identifying metadata needed by local evaluation. +function M.extractDescriptors(item) + if type(item) ~= "table" or item.corrupted or item.duplicated or item.mirrored + or item.unmodifiable or item.unmodifiableExceptChaos then + return {} + end + + local explicitMods = item.explicitMods + if type(explicitMods) ~= "table" then + return {} + end + local groupsByDomain = getHashGroups(item) + local groupLineCounts = {} + local affixLineCounts = {} + local metadataComplete = true + for _, modLine in ipairs(explicitMods) do + local groups = getLineGroups(modLine, groupsByDomain) + if type(groups) == "table" then + for _, index in ipairs(groups) do + local key = groupKey(modLine.domain, index) + groupLineCounts[key] = (groupLineCounts[key] or 0) + 1 + end + end + local fingerprint = getAffixFingerprint(modLine) + if fingerprint then + affixLineCounts[fingerprint] = (affixLineCounts[fingerprint] or 0) + 1 + end + if (modLine.domain == "explicit" or modLine.domain == "crafted") + and (not fingerprint or type(groups) ~= "table" or #groups ~= 1) then + metadataComplete = false + end + end + if not metadataComplete then + return {} + end + + local descriptors = {} + local seenElements = {} + local duplicateElement = false + for lineIndex, modLine in ipairs(explicitMods) do + local domain = modLine.domain + local flags = modLine.flags or {} + local value, element + if type(modLine.description) == "string" then + value, element = modLine.description:match("^%+(%d+%.?%d*)%% to (%a+) Resistance$") + end + local mod = getUniqueMod(modLine) + local magnitudes = mod and mod.magnitudes + local magnitude = type(magnitudes) == "table" and #magnitudes == 1 and magnitudes[1] + local groups = getLineGroups(modLine, groupsByDomain) + local fingerprint = getAffixFingerprint(modLine) + local validGroup = type(groups) == "table" and #groups == 1 + and groupLineCounts[groupKey(domain, groups[1])] == 1 + if (domain == "explicit" or domain == "crafted") and value and elementSet[element] + and not flags.fractured and not flags.unmodifiable and not flags.unmodifiableExceptChaos + and fingerprint and affixLineCounts[fingerprint] == 1 + and magnitude and tonumber(magnitude.min) and tonumber(magnitude.max) + and validGroup then + if seenElements[element] then + duplicateElement = true + else + table.insert(descriptors, { + lineIndex = lineIndex, + element = element, + domain = domain, + tier = mod.tier, + range = { min = tonumber(magnitude.min), max = tonumber(magnitude.max) }, + }) + seenElements[element] = true + end + end + end + + if duplicateElement or #descriptors > 3 then + return {} + end + return descriptors +end + +function M.getAssignments(descriptors) + if type(descriptors) ~= "table" or #descriptors == 0 or #descriptors > 3 then + return {} + end + local sourceElements = {} + for _, descriptor in ipairs(descriptors) do + if not elementSet[descriptor.element] or sourceElements[descriptor.element] then + return {} + end + sourceElements[descriptor.element] = true + end + local assignments = {} + local assignment = {} + local used = {} + local function visit(index, swaps) + if index > #descriptors then + local targets = {} + for descriptorIndex, target in ipairs(assignment) do + targets[descriptorIndex] = target + end + table.insert(assignments, { targets = targets, swaps = swaps }) + return + end + for _, target in ipairs(elements) do + if not used[target] then + used[target] = true + assignment[index] = target + visit(index + 1, swaps + (target == descriptors[index].element and 0 or 1)) + used[target] = nil + end + end + end + visit(1, 0) + return assignments +end + +local function readResistanceLine(modLine) + if not modLine or type(modLine.line) ~= "string" then + return + end + local value, element = modLine.line:match("^%+(%d+%.?%d*)%% to (%a+) Resistance$") + if value and elementSet[element] then + return value, element + end +end + +function M.validateItem(item, descriptors) + if not item or item.corrupted or item.mirrored or item.duplicated then + return false + end + for _, descriptor in ipairs(descriptors or {}) do + local modLine = item.explicitModLines[descriptor.lineIndex] + local _, element = readResistanceLine(modLine) + if element ~= descriptor.element or modLine.fractured + or (descriptor.domain == "crafted") ~= (modLine.crafted == true) then + return false + end + end + return #descriptors > 0 +end + +function M.buildVariant(itemString, descriptors, assignment) + local item = new("Item", itemString) + if not M.validateItem(item, descriptors) then + return + end + local swaps = {} + for index, descriptor in ipairs(descriptors) do + local target = assignment.targets[index] + local modLine = item.explicitModLines[descriptor.lineIndex] + local value, source = readResistanceLine(modLine) + if not target or not elementSet[target] or not value or source ~= descriptor.element then + return + end + if target ~= source then + modLine.line = modLine.line:gsub(" " .. source .. " Resistance$", " " .. target .. " Resistance") + table.insert(swaps, { from = source, to = target, value = tonumber(value) }) + end + end + item:BuildAndParseRaw() + return item, swaps +end + +return M From 44dd8daca81f6f2afd19e0f97f16c8f8576f5b8d Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 21:14:47 +0200 Subject: [PATCH 2/8] Preview estimated resistance swaps in trade results Keep the listed trade item unchanged while Ctrl shows the exact variant used for ranking. Highlight swapped mod lines and clarify that Harvest rolls may change. --- spec/System/TestTradeQuery_spec.lua | 57 +++++++++++++++++++++++++++-- src/Classes/TradeQuery.lua | 46 ++++++++++++++++++++--- src/Classes/TradeQueryGenerator.lua | 4 +- src/Classes/TradeResistanceSwap.lua | 4 +- 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 3adda2841ce..6bcfdbd14a9 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -61,7 +61,7 @@ describe("TradeQuery", function() assert.are.equal(0, #tooltip.lines) end) - it("shows the estimated resistance swap without changing the listed item", function() + it("shows a compact resistance swap without changing the listed item", function() local itemString = "Rarity: RARE\nBehemoth Hold\nCoral Ring\nImplicits: 0\n+17% to Fire Resistance" local tq = newTradeQuery({ resultTbl = { [1] = { [1] = { @@ -72,6 +72,8 @@ describe("TradeQuery", function() output = {}, weight = 1, theoreticalResistanceSwap = { { from = "Fire", to = "Cold", value = 17 } }, + theoreticalResistanceSwapItemString = "Rarity: RARE\nBehemoth Hold\nCoral Ring\nImplicits: 0\n+17% to Cold Resistance", + theoreticalResistanceSwapLineIndexes = { 1 }, } }, } } }, sortedResultTbl = { [1] = { { index = 1 } } }, @@ -85,8 +87,54 @@ describe("TradeQuery", function() for _, line in ipairs(tooltip.lines) do text = text .. (line.text or "") .. "\n" end - assert.is_truthy(text:find("Estimated resistance swap: Fire to Cold %(17%%%)")) - assert.is_truthy(text:find("listed value; Harvest may reroll", 1, true)) + assert.is_truthy(text:find("Estimated swap: Fire -> Cold", 1, true)) + assert.is_truthy(text:find("(roll may change)", 1, true)) + assert.is_truthy(text:find("[Ctrl: compare]", 1, true)) + assert.is_nil(text:find("17%", 1, true)) + assert.are.equal(itemString, tq.resultTbl[1][1].item_string) + end) + + it("highlights every swapped line and leaves other lines unchanged in the Ctrl preview", function() + local itemString = "Rarity: RARE\nBehemoth Hold\nCoral Ring\nImplicits: 0\n+30 to Strength\n+17% to Fire Resistance\n+24% to Cold Resistance" + local tq = newTradeQuery({ + resultTbl = { [1] = { [1] = { + item_string = itemString, + amount = 1, + currency = "chaos", + evaluation = { { + output = {}, + weight = 1, + theoreticalResistanceSwap = { + { from = "Fire", to = "Cold", value = 17 }, + { from = "Cold", to = "Lightning", value = 24 }, + }, + theoreticalResistanceSwapItemString = "Rarity: RARE\nBehemoth Hold\nCoral Ring\nImplicits: 0\n+30 to Strength\n+17% to Cold Resistance\n+24% to Lightning Resistance", + theoreticalResistanceSwapLineIndexes = { 2, 3 }, + } }, + } } }, + sortedResultTbl = { [1] = { { index = 1 } } }, + }) + tq.itemsTab.AddItemTooltip = function(_, tooltip, item) + for _, modLine in ipairs(item.explicitModLines) do + tooltip:AddLine(16, colorCodes.MAGIC .. modLine.line, nil, modLine) + end + end + tq.IsResistanceSwapPreviewActive = function() return true end + local dropdown = buildRow1Dropdown(tq) + local tooltip = new("Tooltip") + + dropdown.tooltipFunc(tooltip, "DROP", 1, nil) + + assert.are.equal(1, #tooltip.childTooltips) + local previewText = "" + for _, line in ipairs(tooltip.childTooltips[1].lines) do + previewText = previewText .. StripEscapes(line.text or "") .. "\n" + end + assert.is_truthy(previewText:find("[Swap] +17% to Cold Resistance", 1, true)) + assert.is_truthy(previewText:find("[Swap] +24% to Lightning Resistance", 1, true)) + assert.is_truthy(previewText:find("Estimated after swap; rolls may change.", 1, true)) + assert.is_nil(previewText:find("[Swap] +30 to Strength", 1, true)) + assert.is_nil(previewText:find("[Swap] +17% to Fire Resistance", 1, true)) assert.are.equal(itemString, tq.resultTbl[1][1].item_string) end) end) @@ -314,6 +362,9 @@ describe("TradeQuery", function() assert.are.equal(2, #swaps) assert.are.same({ from = "Fire", to = "Cold", value = 10 }, swaps[1]) assert.are.same({ from = "Cold", to = "Lightning", value = 20 }, swaps[2]) + assert.are.same({ 1, 2 }, evaluation[1].theoreticalResistanceSwapLineIndexes) + assert.is_truthy(evaluation[1].theoreticalResistanceSwapItemString:find("+10%% to Cold Resistance")) + assert.is_truthy(evaluation[1].theoreticalResistanceSwapItemString:find("+20%% to Lightning Resistance")) assert.are.equal(original, tq.resultTbl[1][1].item_string) end) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 834d610e84e..5f8e0cf61ab 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -805,6 +805,10 @@ function TradeQueryClass:SetNotice(notice_control, msg) notice_control.label = msg end +function TradeQueryClass:IsResistanceSwapPreviewActive() + return IsKeyDown("CTRL") +end + -- Method to reduce the full output to only the values that were 'weighted' function TradeQueryClass:ReduceOutput(output) local smallOutput = {} @@ -890,11 +894,12 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba for _, assignment in ipairs(assignments) do local variant local swaps + local swappedLineIndexes if assignment.swaps == 0 then variant = item swaps = {} else - variant, swaps = tradeResistanceSwap.buildVariant(result.item_string, descriptors, assignment) + variant, swaps, swappedLineIndexes = tradeResistanceSwap.buildVariant(result.item_string, descriptors, assignment) end if variant then local evaluation = evaluateVariant(variant) @@ -904,6 +909,8 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba bestSwapCount = assignment.swaps if assignment.swaps > 0 then bestEvaluation.theoreticalResistanceSwap = swaps + bestEvaluation.theoreticalResistanceSwapItemString = variant:BuildRaw() + bestEvaluation.theoreticalResistanceSwapLineIndexes = swappedLineIndexes end end end @@ -1312,11 +1319,39 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end local descriptions = {} for _, swap in ipairs(swaps) do - table.insert(descriptions, string.format("%s to %s (%g%%)", swap.from, swap.to, swap.value)) + table.insert(descriptions, string.format("%s -> %s", swap.from, swap.to)) end + local label = #swaps == 1 and "Estimated swap: " or "Estimated swaps: " + local rollNote = #swaps == 1 and " (roll may change)" or " (rolls may change)" + local compareHint = evaluation.theoreticalResistanceSwapItemString and colorCodes.TIP .. " [Ctrl: compare]" or "" tooltip:AddSeparator(10) - tooltip:AddLine(16, "^7Estimated resistance swap: " .. table.concat(descriptions, ", ")) - tooltip:AddLine(16, "^8Uses the listed value; Harvest may reroll.") + tooltip:AddLine(16, "^7" .. label .. table.concat(descriptions, ", ") .. "^8" .. rollNote .. compareHint) + return evaluation + end + local function addResistanceSwapPreviewIfApplicable(tooltip, evaluation, tooltipSlot) + if not evaluation or not evaluation.theoreticalResistanceSwapItemString or not self:IsResistanceSwapPreviewActive() then + return + end + local previewItem = new("Item", evaluation.theoreticalResistanceSwapItemString) + local previewTooltip = tooltip.resistanceSwapPreviewTooltip or new("Tooltip") + tooltip.resistanceSwapPreviewTooltip = previewTooltip + previewTooltip:Clear() + self.itemsTab:AddItemTooltip(previewTooltip, previewItem, tooltipSlot) + local swappedModLines = {} + for _, lineIndex in ipairs(evaluation.theoreticalResistanceSwapLineIndexes or {}) do + local modLine = previewItem.explicitModLines[lineIndex] + if modLine then + swappedModLines[modLine] = true + end + end + for _, line in ipairs(previewTooltip.lines) do + if line.modLine and swappedModLines[line.modLine] and line.text then + line.text = colorCodes.WARNING .. "[Swap] " .. StripEscapes(line.text) + end + end + previewTooltip:AddSeparator(10) + previewTooltip:AddLine(14, colorCodes.TIP .. "Estimated after swap; rolls may change.") + tooltip.childTooltips = { previewTooltip } end controls["resultDropdown"..row_idx].tooltipFunc = function(tooltip, dropdown_mode, dropdown_index, dropdown_display_string) local sortedRow = self.sortedResultTbl[row_idx] @@ -1333,7 +1368,8 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite local tooltipSlot = slotTbl.selectedJewelNodeId and self.itemsTab.sockets[slotTbl.selectedJewelNodeId] or activeSlot self.itemsTab:AddItemTooltip(tooltip, item, tooltipSlot) addMegalomaniacCompareToTooltipIfApplicable(tooltip, pb_index) - addResistanceSwapToTooltipIfApplicable(tooltip, result) + local resistanceSwapEvaluation = addResistanceSwapToTooltipIfApplicable(tooltip, result) + addResistanceSwapPreviewIfApplicable(tooltip, resistanceSwapEvaluation, tooltipSlot) tooltip:AddSeparator(10) tooltip:AddLine(16, string.format("^7Price: %s %s", result.amount, result.currency)) end diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 91d719c9f74..0279089e5c1 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1337,12 +1337,12 @@ Remove: %s will be removed from the search results.]], term, term, term) if not context.slotTbl.unique then controls.groupResists = new("CheckBoxControl", { "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 18 }, "Resistance swaps:", function(state) end) controls.groupResists.state = self.lastGroupResists == true - controls.groupResists.tooltipText = "Searches total resistance and estimates Fire/Cold/Lightning swaps for Stat Value.\nHarvest may reroll values." + controls.groupResists.tooltipText = "Searches Fire, Cold, and Lightning Resistance as one total.\nResults are sorted using the best estimated swap; rolls may change." updateLastAnchor(controls.groupResists) controls.includeResistCaps = new("CheckBoxControl", { "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 18 }, "Resistance caps:", function(state) end) controls.includeResistCaps.state = self.lastIncludeResistCaps == true - controls.includeResistCaps.tooltipText = "Requires listed resistance to reach current elemental and Chaos caps; extra resistance is not weighted.\nWith swaps, filters by total resistance first; fetched items need a valid estimate." + controls.includeResistCaps.tooltipText = "Only shows items that meet current Elemental and Chaos Resistance caps.\nResistance above those caps does not affect sorting." updateLastAnchor(controls.includeResistCaps) end diff --git a/src/Classes/TradeResistanceSwap.lua b/src/Classes/TradeResistanceSwap.lua index 69507021407..a2777a11f85 100644 --- a/src/Classes/TradeResistanceSwap.lua +++ b/src/Classes/TradeResistanceSwap.lua @@ -204,6 +204,7 @@ function M.buildVariant(itemString, descriptors, assignment) return end local swaps = {} + local swappedLineIndexes = {} for index, descriptor in ipairs(descriptors) do local target = assignment.targets[index] local modLine = item.explicitModLines[descriptor.lineIndex] @@ -214,10 +215,11 @@ function M.buildVariant(itemString, descriptors, assignment) if target ~= source then modLine.line = modLine.line:gsub(" " .. source .. " Resistance$", " " .. target .. " Resistance") table.insert(swaps, { from = source, to = target, value = tonumber(value) }) + table.insert(swappedLineIndexes, descriptor.lineIndex) end end item:BuildAndParseRaw() - return item, swaps + return item, swaps, swappedLineIndexes end return M From 5be43b59e6cb5e203e817a4a569263048a0aa18f Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Mon, 10 Aug 2026 01:13:53 +0200 Subject: [PATCH 3/8] Keep resistance swap evaluation responsive Evaluate fetched variants cooperatively, reuse unchanged cached results, and skip permutations only when cap state and resistance dependencies prove they cannot affect the selected result. --- spec/System/TestTradeQuery_spec.lua | 204 ++++++++++++-- src/Classes/TradeQuery.lua | 418 ++++++++++++++++++++++------ src/Classes/TradeQueryGenerator.lua | 2 +- 3 files changed, 519 insertions(+), 105 deletions(-) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 6bcfdbd14a9..9067022e337 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -6,6 +6,40 @@ describe("TradeQuery", function() mock_tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) end) + describe("cooperative result evaluation", function() + it("resumes fetched result work over multiple frames", function() + local tradeQuery = new("TradeQuery", { 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) + end) describe("result dropdown tooltipFunc", function() -- Builds a TradeQuery with the strict minimum needed for -- PriceItemRowDisplay to construct row 1 without exploding. Only the @@ -319,6 +353,99 @@ describe("TradeQuery", function() end end + it("uses only the listed item when it is already capped and resistance state is irrelevant", function() + local tq = newEvaluationQuery( + { "+10% to Fire Resistance", "+20% to Cold Resistance" }, + { descriptor(1, "Fire"), descriptor(2, "Cold") }, true, true) + local calls = 0 + tq.HasResistanceSwapOutputDependency = function() return false end + + local evaluation = tq:GetResultEvaluation(1, 1, function() + calls = calls + 1 + return { + Life = 100, + MissingFireResist = 0, + MissingColdResist = 0, + MissingLightningResist = 0, + MissingChaosResist = 0, + } + end, { Life = 100 }) + + assert.are.equal(1, calls) + assert.are.equal(1, #evaluation) + assert.is_nil(evaluation[1].theoreticalResistanceSwap) + end) + + it("only evaluates swaps that can feed an elemental resistance deficit", function() + local tq = newEvaluationQuery( + { "+10% to Fire Resistance" }, { descriptor(1, "Fire") }, true, true) + local calls = 0 + tq.HasResistanceSwapOutputDependency = function() return false end + + local evaluation = tq:GetResultEvaluation(1, 1, function(args) + calls = calls + 1 + return scoreAndCapsFromElements( + { Fire = 0, Cold = 10, Lightning = 0, Chaos = 0 })(args) + end, { Life = 100 }) + + assert.are.equal(2, calls) + assert.are.equal(1, #evaluation) + assert.are.equal("Cold", evaluation[1].theoreticalResistanceSwap[1].to) + end) + + it("keeps resistance swaps when the build depends on resistance state", function() + local tq = newEvaluationQuery( + { "+10% to Fire Resistance", "+20% to Cold Resistance" }, + { descriptor(1, "Fire"), descriptor(2, "Cold") }, true, true) + local calls = 0 + local calc = scoreAndCapsFromElements({ Fire = 0, Cold = 0, Lightning = 0, Chaos = 0 }, + { Fire = 1, Cold = 2, Lightning = 3 }) + tq.HasResistanceSwapOutputDependency = function() return true end + + tq:GetResultEvaluation(1, 1, function(args) + calls = calls + 1 + return calc(args) + end, { Life = 100 }) + + assert.are.equal(6, calls) + end) + + it("detects direct and modifier-based resistance output dependencies", function() + local tq = newEvaluationQuery({ "+10% to Fire Resistance" }, { descriptor(1, "Fire") }, true, true) + tq.itemsTab.build = { calcsTab = { mainEnv = { player = { + modDB = { mods = { } }, + } } } } + + assert.is_false(tq:HasResistanceSwapOutputDependency()) + assert.is_true(tq:HasResistanceSwapOutputDependency({ modList = { { + name = "FirePenIncreasedByUncappedFireRes", + type = "FLAG", + } } })) + assert.is_true(tq:HasResistanceSwapOutputDependency({ modList = { { + name = "DamageIncreasedByOvercappedColdRes", + type = "FLAG", + } } })) + + tq.statSortSelectionList = { { stat = "FireResistTotal", weightMult = 1 } } + assert.is_true(tq:HasResistanceSwapOutputDependency()) + + tq.statSortSelectionList = { { stat = "Life", weightMult = 1 } } + tq.itemsTab.build.calcsTab.mainEnv.player.modDB.mods.LifeRegen = { { + name = "LifeRegen", + type = "BASE", + [1] = { type = "PerStat", stat = "FireResistTotal" }, + } } + assert.is_true(tq:HasResistanceSwapOutputDependency()) + + tq.itemsTab.build.calcsTab.mainEnv.player.modDB.mods = { + FirePenIncreasedByUncappedFireRes = { { + name = "FirePenIncreasedByUncappedFireRes", + type = "FLAG", + } }, + } + assert.is_true(tq:HasResistanceSwapOutputDependency()) + end) + it("evaluates exactly 3, 6, and 6 distinct-target assignments for one to three candidates", function() local cases = { { @@ -349,6 +476,22 @@ describe("TradeQuery", function() end end) + it("provides a cooperative yield point after each calculated assignment", function() + local calls = 0 + local yields = 0 + local tq = newEvaluationQuery( + { "+10% to Fire Resistance", "+20% to Cold Resistance" }, + { descriptor(1, "Fire"), descriptor(2, "Cold") }, true) + + tq:GetResultEvaluation(1, 1, + scoreFromElements({ Fire = 1, Cold = 2, Lightning = 3 }, function() calls = calls + 1 end), + { Life = 100 }, + function() yields = yields + 1 end) + + assert.are.equal(6, calls) + assert.are.equal(calls, yields) + end) + it("selects the best permutation and leaves the listed item unchanged", function() local tq = newEvaluationQuery( { "+10% to Fire Resistance", "+20% to Cold Resistance" }, @@ -407,17 +550,18 @@ describe("TradeQuery", function() assert.is_nil(evaluation[1].theoreticalResistanceSwap) end) - it("rejects an elemental total that cannot be split across the missing caps", function() + it("retains the best partial assignment when the elemental total cannot reach every cap", function() local tq = newEvaluationQuery( { "+80% to Fire Resistance", "+30% to Chaos Resistance" }, { descriptor(1, "Fire") }, true, true) local evaluation = tq:GetResultEvaluation(1, 1, scoreAndCapsFromElements( { Fire = 40, Cold = 40, Lightning = 0, Chaos = 30 }), { Life = 100 }) - assert.are.equal(0, #evaluation) + assert.are.equal(1, #evaluation) + assert.are.equal(40, evaluation[1].resistanceCapShortfall) end) - it("validates caps without simulating swaps when only resistance caps are enabled", function() + it("records cap shortfall without dropping items when swaps are disabled", function() local valid = newEvaluationQuery( { "+40% to Fire Resistance", "+30% to Chaos Resistance" }, nil, false, true) local invalid = newEvaluationQuery( @@ -425,34 +569,42 @@ describe("TradeQuery", function() local calc = scoreAndCapsFromElements({ Fire = 40, Cold = 0, Lightning = 0, Chaos = 30 }) assert.are.equal(1, #valid:GetResultEvaluation(1, 1, calc, { Life = 100 })) - assert.are.equal(0, #invalid:GetResultEvaluation(1, 1, calc, { Life = 100 })) + local invalidEvaluation = invalid:GetResultEvaluation(1, 1, calc, { Life = 100 }) + assert.are.equal(1, #invalidEvaluation) + assert.are.equal(1, invalidEvaluation[1].resistanceCapShortfall) end) - it("rejects an item that only misses the required Chaos resistance", function() + it("retains an item that only misses the requested Chaos resistance", function() local tq = newEvaluationQuery( { "+40% to Fire Resistance", "+29% to Chaos Resistance" }, nil, false, true) local evaluation = tq:GetResultEvaluation(1, 1, scoreAndCapsFromElements({ Fire = 40, Cold = 0, Lightning = 0, Chaos = 30 }), { Life = 100 }) - assert.are.equal(0, #evaluation) + assert.are.equal(1, #evaluation) + assert.are.equal(1, evaluation[1].resistanceCapShortfall) end) - it("removes uncapped results before any result sort is applied", function() + it("sorts retained capped and uncapped results by requested stat value", function() local tq = new("TradeQuery", { itemsTab = {} }) tq.resultTbl[1] = { { id = "uncapped", resistanceCapsRequired = true }, { id = "capped", resistanceCapsRequired = true }, { id = "unrestricted" }, } + tq.sortModes = { StatValue = "statValue" } + tq.itemsTab.build = { calcsTab = { GetMiscCalculator = function() + return function() return { } end, { } + end } } tq.GetResultEvaluation = function(_, _, resultIndex) - return resultIndex == 1 and {} or { { weight = 1 } } + return { { weight = 4 - resultIndex, resistanceCapShortfall = resultIndex == 1 and 10 or 0 } } end - tq:FilterToResistanceCapItems(1) + local sorted = tq:SortFetchResults(1, tq.sortModes.StatValue) - assert.are.same({ "capped", "unrestricted" }, { - tq.resultTbl[1][1].id, - tq.resultTbl[1][2].id, + assert.are.same({ "uncapped", "capped", "unrestricted" }, { + tq.resultTbl[1][sorted[1].index].id, + tq.resultTbl[1][sorted[2].index].id, + tq.resultTbl[1][sorted[3].index].id, }) end) @@ -490,12 +642,14 @@ describe("TradeQuery", function() end, } } - tq:FilterToResistanceCapItems(1) - assert.are.equal(0, #tq.resultTbl[1]) + local first = tq:GetResultEvaluation(1, 1) + assert.are.equal(1, #first) + assert.are.equal(10, first[1].resistanceCapShortfall) requiredFire = 40 - tq:FilterToResistanceCapItems(1) - assert.are.equal(1, #tq.resultTbl[1]) + local second = tq:GetResultEvaluation(1, 1) + assert.are.equal(1, #second) + assert.are.equal(0, second[1].resistanceCapShortfall) end) it("reuses the single best evaluation while the build and weights are unchanged", function() @@ -515,5 +669,23 @@ describe("TradeQuery", function() assert.are.equal(first, second) assert.are.equal(1, #second) end) + + it("reuses the cached evaluation when sorting supplies a shared calculator", function() + local calls = 0 + local tq = newEvaluationQuery({ "+10% to Fire Resistance" }, { descriptor(1, "Fire") }, true) + local calc = scoreFromElements({ Fire = 1, Cold = 2, Lightning = 3 }, function() calls = calls + 1 end) + local baseOutput = { Life = 100 } + tq.itemsTab.build = { calcsTab = { + GetMiscCalculator = function() + return calc, baseOutput + end, + } } + + local first = tq:GetResultEvaluation(1, 1) + local second = tq:GetResultEvaluation(1, 1, calc, baseOutput) + + assert.are.equal(3, calls) + assert.are.equal(first, second) + end) end) end) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 5f8e0cf61ab..bea83b1dde2 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -30,6 +30,18 @@ local function meetsResistanceCaps(output) return true end +local function getResistanceCapShortfall(output) + local shortfall = 0 + for _, resistanceType in ipairs({ "Fire", "Cold", "Lightning", "Chaos" }) do + local missing = output["Missing" .. resistanceType .. "Resist"] + if type(missing) ~= "number" then + return math.huge + end + shortfall = shortfall + m_max(missing, 0) + end + return shortfall +end + local function getResistanceState(output) local state = {} for _, resistanceType in ipairs({ "Fire", "Cold", "Lightning", "Chaos" }) do @@ -41,6 +53,89 @@ local function getResistanceState(output) return state end +local function getMissingElementalResistanceTargets(output) + local targets = { } + for _, element in ipairs({ "Fire", "Cold", "Lightning" }) do + local missing = output["Missing" .. element .. "Resist"] + if type(missing) ~= "number" then + return nil + end + if missing > 0 then + targets[element] = true + end + end + return targets +end + +local function assignmentCanRepairResistanceDeficit(descriptors, assignment, missingTargets, canFreeBenchGroup) + if not missingTargets then + return true + end + for index, target in ipairs(assignment.targets or { }) do + local source = descriptors[index].element + if target ~= source and (missingTargets[target] + or canFreeBenchGroup and missingTargets[source]) then + return true + end + end + return false +end + +local function isResistanceStateFlag(value) + return type(value) == "string" + and (value:find("Uncapped", 1, true) or value:find("Overcapped", 1, true)) + and (value:find("FireRes", 1, true) + or value:find("ColdRes", 1, true) + or value:find("LightningRes", 1, true)) +end + +local function isResistanceStateStat(value) + return type(value) == "string" and ( + value:find("FireResist", 1, true) + or value:find("ColdResist", 1, true) + or value:find("LightningResist", 1, true) + ) +end + +local function modUsesResistanceState(mod) + for _, tag in ipairs(mod or { }) do + if type(tag) == "table" then + for _, value in pairs(tag) do + if isResistanceStateStat(value) then + return true + end + end + end + end + return false +end + +local function modStoreUsesResistanceState(store, visited) + if type(store) ~= "table" or visited[store] then + return false + end + visited[store] = true + if store.mods then + for name, modList in pairs(store.mods) do + if isResistanceStateFlag(name) then + return true + end + for _, mod in ipairs(modList) do + if modUsesResistanceState(mod) then + return true + end + end + end + else + for _, mod in ipairs(store) do + if isResistanceStateFlag(mod.name) or modUsesResistanceState(mod) then + return true + end + end + end + return modStoreUsesResistanceState(store.parent, visited) +end + ---@class TradeQuery local TradeQueryClass = newClass("TradeQuery") @@ -82,6 +177,11 @@ 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 = {} self.tradeQueryRequests = new("TradeQueryRequests"):TradeQueryRequests() if not main.api then @@ -467,7 +567,7 @@ 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) + self:StartResultEvaluation(row_idx) end end) self.controls.itemSortSelection.tooltipText = @@ -678,6 +778,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 @@ -771,7 +872,7 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) self.statSortSelectionList = statSortSelectionList end for row_idx in pairs(self.resultTbl) do - self:UpdateControlsWithItems(row_idx) + self:StartResultEvaluation(row_idx) end end) controls.cancel = new("ButtonControl"):ButtonControl({ "BOTTOM", nil, "BOTTOM" }, { 0, -10, 80, 20 }, "Cancel", function() @@ -805,6 +906,85 @@ 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:StartResultEvaluation(rowIdx) + local results = self.unfilteredResultTbl[rowIdx] or self.resultTbl[rowIdx] or { } + 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:IsResistanceSwapPreviewActive() return IsKeyDown("CTRL") end @@ -823,32 +1003,68 @@ 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] = { } - end - if not self.lastComparedWeightList[row_idx] then - self.lastComparedWeightList[row_idx] = { } +function TradeQueryClass:HasResistanceSwapOutputDependency(item) + for _, stat in ipairs(self.statSortSelectionList or { }) do + if isResistanceStateStat(stat.stat) then + return true end - if not self.resistanceBaseOutput[row_idx] then - self.resistanceBaseOutput[row_idx] = { } + end + local visited = { } + if item and (modStoreUsesResistanceState(item.modList, visited) + or modStoreUsesResistanceState(item.baseModList, visited)) then + return true + end + for _, modList in pairs(item and item.slotModList or { }) do + if modStoreUsesResistanceState(modList, visited) then + return true end - local resistanceBaseOutput = result.resistanceCapsRequired and getResistanceState(baseOutput) - -- 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]) - and (not result.resistanceCapsRequired or tableDeepEquals(resistanceBaseOutput, self.resistanceBaseOutput[row_idx][result_index])) then - return result.evaluation + end + local calcsTab = self.itemsTab.build and self.itemsTab.build.calcsTab + local env = calcsTab and calcsTab.mainEnv + local player = env and env.player + if not player or not player.modDB then + -- Without the calculated modifier graph, irrelevance cannot be proven. + return true + end + if modStoreUsesResistanceState(player.modDB, visited) then + return true + end + for _, activeSkill in ipairs(player.activeSkillList or { }) do + if modStoreUsesResistanceState(activeSkill.skillModList, visited) + or modStoreUsesResistanceState(activeSkill.modList, visited) then + return true end - self.onlyWeightedBaseOutput[row_idx][result_index] = onlyWeightedBaseOutput - self.lastComparedWeightList[row_idx][result_index] = self.statSortSelectionList - self.resistanceBaseOutput[row_idx][result_index] = resistanceBaseOutput end + return false +end + +-- Method to evaluate a result by getting it's output and weight +function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, baseOutput, yieldFunc) + local result = self.resultTbl[row_idx][result_index] + 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 + if not self.resistanceBaseOutput[row_idx] then + self.resistanceBaseOutput[row_idx] = { } + end + local resistanceBaseOutput = result.resistanceCapsRequired and getResistanceState(baseOutput) + -- A shared calculator is an optimisation, not a cache bypass. Reuse the result + -- whenever the build outputs, selected weights, and resistance state still match. + if result.evaluation and tableDeepEquals(onlyWeightedBaseOutput, self.onlyWeightedBaseOutput[row_idx][result_index]) + and tableDeepEquals(self.statSortSelectionList, self.lastComparedWeightList[row_idx][result_index]) + and (not result.resistanceCapsRequired or tableDeepEquals(resistanceBaseOutput, self.resistanceBaseOutput[row_idx][result_index])) then + return result.evaluation + end + self.onlyWeightedBaseOutput[row_idx][result_index] = onlyWeightedBaseOutput + self.lastComparedWeightList[row_idx][result_index] = self.statSortSelectionList + self.resistanceBaseOutput[row_idx][result_index] = resistanceBaseOutput local slotTbl = self.slotTables[row_idx] local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId local slotName = jewelNodeId and "Jewel " .. tostring(jewelNodeId) or slotTbl.slotName @@ -857,10 +1073,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 @@ -882,32 +1105,42 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba local bestSwapCount local function evaluateVariant(variant) local fullOutput = calcFunc({ repSlotName = slotName, repItem = variant }) - if result.resistanceCapsRequired and not meetsResistanceCaps(fullOutput) then - return + if yieldFunc then + yieldFunc() end local output = self:ReduceOutput(fullOutput) return { output = output, weight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, output, self.statSortSelectionList), - } - end + resistanceCapShortfall = result.resistanceCapsRequired + and getResistanceCapShortfall(fullOutput) or 0, + }, fullOutput + end + local listedEvaluation, listedFullOutput = evaluateVariant(item) + bestEvaluation = listedEvaluation + bestSwapCount = 0 + local function isBetterEvaluation(evaluation, swapCount) + if result.resistanceCapsRequired + and evaluation.resistanceCapShortfall ~= bestEvaluation.resistanceCapShortfall then + return evaluation.resistanceCapShortfall < bestEvaluation.resistanceCapShortfall + end + return evaluation.weight > bestEvaluation.weight + or evaluation.weight == bestEvaluation.weight and swapCount < bestSwapCount + end + local resistanceStateIndependent = result.resistanceCapsRequired + and not self:HasResistanceSwapOutputDependency(item) + local skipSwaps = resistanceStateIndependent and meetsResistanceCaps(listedFullOutput) + local missingTargets = resistanceStateIndependent + and getMissingElementalResistanceTargets(listedFullOutput) or nil for _, assignment in ipairs(assignments) do - local variant - local swaps - local swappedLineIndexes - if assignment.swaps == 0 then - variant = item - swaps = {} - else - variant, swaps, swappedLineIndexes = tradeResistanceSwap.buildVariant(result.item_string, descriptors, assignment) - end - if variant then - local evaluation = evaluateVariant(variant) - if evaluation and (not bestEvaluation or evaluation.weight > bestEvaluation.weight - or evaluation.weight == bestEvaluation.weight and assignment.swaps < bestSwapCount) then - bestEvaluation = evaluation - bestSwapCount = assignment.swaps - if assignment.swaps > 0 then + if assignment.swaps > 0 and not skipSwaps + and assignmentCanRepairResistanceDeficit(descriptors, assignment, missingTargets, false) then + local variant, swaps, swappedLineIndexes = tradeResistanceSwap.buildVariant(result.item_string, descriptors, assignment) + if variant then + local evaluation = evaluateVariant(variant) + if evaluation and isBetterEvaluation(evaluation, assignment.swaps) then + bestEvaluation = evaluation + bestSwapCount = assignment.swaps bestEvaluation.theoreticalResistanceSwap = swaps bestEvaluation.theoreticalResistanceSwapItemString = variant:BuildRaw() bestEvaluation.theoreticalResistanceSwapLineIndexes = swappedLineIndexes @@ -915,12 +1148,7 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba end end end - if bestEvaluation then - result.evaluation = { bestEvaluation } - else - local evaluation = #assignments == 0 and evaluateVariant(item) - result.evaluation = evaluation and { evaluation } or {} - end + result.evaluation = { bestEvaluation } end return result.evaluation end @@ -943,6 +1171,7 @@ function TradeQueryClass:UpdateDropdownList(row_idx) self.controls["resultDropdown".. row_idx]:SetList(dropdownLabels) end function TradeQueryClass:ResetResultRow(rowIdx) + self:CancelResultEvaluation(rowIdx) self.itemIndexTbl[rowIdx] = nil self.sortedResultTbl[rowIdx] = nil self.resultTbl[rowIdx] = nil @@ -954,15 +1183,15 @@ 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) if self.unfilteredResultTbl[row_idx] then - self:FilterToResistanceCapItems(row_idx) + self.resultTbl[row_idx] = self.unfilteredResultTbl[row_idx] end 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 @@ -999,18 +1228,38 @@ 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 end + local function makeResultEntry(resultIndex, outputAttr) + return { + outputAttr = outputAttr, + index = resultIndex, + } + end --- @return table? local function getPriceTable() --- @type table @@ -1028,15 +1277,16 @@ function TradeQueryClass:SortFetchResults(row_idx, mode) end local newTbl = {} if mode == self.sortModes.Weight then - for index, _ in pairs(self.resultTbl[row_idx]) do - t_insert(newTbl, { outputAttr = index, index = index }) + for index = 1, #self.resultTbl[row_idx] do + t_insert(newTbl, makeResultEntry(index, index)) end + table.sort(newTbl, function(a, b) return a.outputAttr < b.outputAttr end) return newTbl elseif mode == self.sortModes.StatValue then for result_index = 1, #self.resultTbl[row_idx] do - t_insert(newTbl, { outputAttr = getResultWeight(result_index), index = result_index }) + t_insert(newTbl, makeResultEntry(result_index, getResultWeight(result_index))) end - table.sort(newTbl, function(a,b) return a.outputAttr > b.outputAttr end) + table.sort(newTbl, function(a, b) return a.outputAttr > b.outputAttr end) elseif mode == self.sortModes.StatValuePrice then local priceTable = getPriceTable() if priceTable == nil then @@ -1054,20 +1304,19 @@ function TradeQueryClass:SortFetchResults(row_idx, mode) -- scaling factor for price local k = 0.1 - t_insert(newTbl, - { outputAttr = getResultWeight(result_index) - k * math.log(priceTable[result_index], 10), index = - result_index }) + t_insert(newTbl, makeResultEntry(result_index, + getResultWeight(result_index) - k * math.log(priceTable[result_index], 10))) end - table.sort(newTbl, function(a,b) return a.outputAttr > b.outputAttr end) + table.sort(newTbl, function(a, b) return a.outputAttr > b.outputAttr end) elseif mode == self.sortModes.Price then local priceTable = getPriceTable() if priceTable == nil then return nil, "MissingConversionRates" end - for result_index, price in pairs(priceTable) do - t_insert(newTbl, { outputAttr = price, index = result_index }) + for result_index, price in ipairs(priceTable) do + t_insert(newTbl, makeResultEntry(result_index, price)) end - table.sort(newTbl, function(a,b) return a.outputAttr < b.outputAttr end) + table.sort(newTbl, function(a, b) return a.outputAttr < b.outputAttr end) else return nil, "InvalidSort" end @@ -1089,17 +1338,6 @@ function TradeQueryClass:FilterToSafeItems(itemEntries, slotName) return itemsSafe end -function TradeQueryClass:FilterToResistanceCapItems(row_idx) - self.resultTbl[row_idx] = self.unfilteredResultTbl[row_idx] or self.resultTbl[row_idx] or {} - local cappedItems = {} - for resultIndex, itemEntry in ipairs(self.resultTbl[row_idx]) do - if not itemEntry.resistanceCapsRequired or #self:GetResultEvaluation(row_idx, resultIndex) > 0 then - t_insert(cappedItems, itemEntry) - end - end - self.resultTbl[row_idx] = cappedItems -end - function TradeQueryClass:SearchGeneratedQuery(queryOptions, query, callback, params) local searchMethod = queryOptions and queryOptions.weightAdjustedSearch == false and self.tradeQueryRequests.SearchWithQuery or self.tradeQueryRequests.SearchWithQueryWeightAdjusted @@ -1138,6 +1376,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:CancelResultEvaluation(row_idx) self.tradeQueryGenerator:RequestQuery(activeSlot, { slotTbl = slotTbl, controls = controls, row_idx = row_idx }, self.statSortSelectionList, function(context, query, errMsg, queryOptions) if errMsg then self:SetNotice(context.controls.pbNotice, colorCodes.NEGATIVE .. errMsg) @@ -1188,8 +1427,7 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro self.unfilteredResultTbl[context.row_idx] = queryOptions and queryOptions.includeResistCaps and itemsSafe or nil 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) @@ -1248,6 +1486,7 @@ 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() + self:CancelResultEvaluation(row_idx) controls["priceButton"..row_idx].label = "Searching..." local url = controls["uri" .. row_idx].buf if not url:find("^https://") then @@ -1262,18 +1501,21 @@ 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 + if errMsg then + controls["priceButton"..row_idx].label = "Price Item" 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 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() diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 0279089e5c1..caf9595fea5 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1342,7 +1342,7 @@ Remove: %s will be removed from the search results.]], term, term, term) controls.includeResistCaps = new("CheckBoxControl", { "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 18 }, "Resistance caps:", function(state) end) controls.includeResistCaps.state = self.lastIncludeResistCaps == true - controls.includeResistCaps.tooltipText = "Only shows items that meet current Elemental and Chaos Resistance caps.\nResistance above those caps does not affect sorting." + controls.includeResistCaps.tooltipText = "Targets the current Elemental and Chaos Resistance caps when searching and evaluating items.\nItems that still miss a cap remain visible, and the selected result sort is unchanged." updateLastAnchor(controls.includeResistCaps) end From 4c6c7e661a3acab451820fee6b3d8531d4d88772 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 11 Aug 2026 00:14:05 +0200 Subject: [PATCH 4/8] Use accepted wording in resistance swap test Replace the cspell-rejected "reranking" term without changing test behavior. --- spec/System/TestTradeQuery_spec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 9067022e337..c03ea8b5cb0 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -522,7 +522,7 @@ describe("TradeQuery", function() assert.is_nil(evaluation[1].theoreticalResistanceSwap) end) - it("uses one baseline calculation when reranking is disabled or ineligible", function() + it("uses one baseline calculation when ranking is disabled or ineligible", function() local cases = { newEvaluationQuery({ "+10% to Fire Resistance" }, { descriptor(1, "Fire") }, false), newEvaluationQuery({ "+10% to Fire Resistance" }, { descriptor(1, "Cold") }, true), From 70dd53299e27b68c8836a43383315c9b0f901413 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 11 Aug 2026 23:27:49 +0200 Subject: [PATCH 5/8] Clarify resistance swap dependency pruning Remove an unused integration-only argument and align nearby comments with project terminology. Behavior is unchanged on the standalone feature branch. --- src/Classes/TradeQuery.lua | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index bea83b1dde2..ce735372a68 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -67,14 +67,13 @@ local function getMissingElementalResistanceTargets(output) return targets end -local function assignmentCanRepairResistanceDeficit(descriptors, assignment, missingTargets, canFreeBenchGroup) +local function assignmentCanRepairResistanceDeficit(descriptors, assignment, missingTargets) if not missingTargets then return true end for index, target in ipairs(assignment.targets or { }) do local source = descriptors[index].element - if target ~= source and (missingTargets[target] - or canFreeBenchGroup and missingTargets[source]) then + if target ~= source and missingTargets[target] then return true end end @@ -1023,7 +1022,7 @@ function TradeQueryClass:HasResistanceSwapOutputDependency(item) local env = calcsTab and calcsTab.mainEnv local player = env and env.player if not player or not player.modDB then - -- Without the calculated modifier graph, irrelevance cannot be proven. + -- Without the calculated modifier graph, the resistance state cannot be proven irrelevant. return true end if modStoreUsesResistanceState(player.modDB, visited) then @@ -1055,7 +1054,7 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba self.resistanceBaseOutput[row_idx] = { } end local resistanceBaseOutput = result.resistanceCapsRequired and getResistanceState(baseOutput) - -- A shared calculator is an optimisation, not a cache bypass. Reuse the result + -- A shared calculator is an optimization, not a cache bypass. Reuse the result -- whenever the build outputs, selected weights, and resistance state still match. if result.evaluation and tableDeepEquals(onlyWeightedBaseOutput, self.onlyWeightedBaseOutput[row_idx][result_index]) and tableDeepEquals(self.statSortSelectionList, self.lastComparedWeightList[row_idx][result_index]) @@ -1134,7 +1133,7 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba and getMissingElementalResistanceTargets(listedFullOutput) or nil for _, assignment in ipairs(assignments) do if assignment.swaps > 0 and not skipSwaps - and assignmentCanRepairResistanceDeficit(descriptors, assignment, missingTargets, false) then + and assignmentCanRepairResistanceDeficit(descriptors, assignment, missingTargets) then local variant, swaps, swappedLineIndexes = tradeResistanceSwap.buildVariant(result.item_string, descriptors, assignment) if variant then local evaluation = evaluateVariant(variant) From 12395643a98765648ac4370b2c8833b0f1a70eee Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Wed, 12 Aug 2026 22:24:44 +0200 Subject: [PATCH 6/8] Protect Trader result fetch state Track fetch identity independently from UI labels and invalidate stale selections while cooperative evaluation is pending. Clear capped candidate state when pasted URL results replace a generated search. --- spec/System/TestTradeQueryGenerator_spec.lua | 8 +- spec/System/TestTradeQueryRequests_spec.lua | 4 +- spec/System/TestTradeQuery_spec.lua | 144 ++++++++++++++++++- src/Classes/TradeQuery.lua | 127 ++++++++++++---- src/Classes/TradeQueryGenerator.lua | 4 +- src/Classes/TradeResistanceSwap.lua | 2 +- 6 files changed, 251 insertions(+), 38 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 8673187be9b..6a3793c398a 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -166,12 +166,12 @@ describe("TradeQueryGenerator", function() end) it("annotates weights through the real GenerateModWeights method", function() - local queryGen = new("TradeQueryGenerator", { itemsTab = {} }) + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) queryGen.modWeights = {} queryGen.alreadyWeightedMods = {} queryGen.calcContext = { itemCategory = "Ring", - testItem = new("Item", "Rarity: RARE\nTest Ring\nCoral Ring\nImplicits: 0"), + testItem = new("Item"):Item("Rarity: RARE\nTest Ring\nCoral Ring\nImplicits: 0"), baseOutput = { Life = 100 }, baseStatValue = 1000, calcFunc = function() return { Life = 110 } end, @@ -196,13 +196,13 @@ describe("TradeQueryGenerator", function() local function finishQuery(options, weights) options = options or {} - local queryGen = new("TradeQueryGenerator", { itemsTab = {} }) + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) queryGen.tradeTypeIndex = 4 queryGen.modWeights = weights queryGen.calcContext = { itemCategoryQueryStr = "accessory.ring", special = {}, - testItem = new("Item", "Rarity: RARE\nTest Ring\nCoral Ring\nImplicits: 0"), + testItem = new("Item"):Item("Rarity: RARE\nTest Ring\nCoral Ring\nImplicits: 0"), baseOutput = { Life = 100 }, baseStatValue = 1000, calcFunc = function() return { Life = 100 } end, diff --git a/spec/System/TestTradeQueryRequests_spec.lua b/spec/System/TestTradeQueryRequests_spec.lua index 5933b93e545..271b787b2a4 100644 --- a/spec/System/TestTradeQueryRequests_spec.lua +++ b/spec/System/TestTradeQueryRequests_spec.lua @@ -336,7 +336,7 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] local result = fetchSingle(item) assert.are.equal(1, #result.resistanceSwapDescriptors) assert.are.equal(1, result.resistanceSwapDescriptors[1].lineIndex) - local parsedItem = new("Item", result.item_string) + local parsedItem = new("Item"):Item(result.item_string) assert.are.equal("+17% to Fire Resistance", parsedItem.explicitModLines[1].line) assert.are.equal("11% of Physical Damage from Hits taken as Fire Damage", parsedItem.explicitModLines[2].line) end) @@ -397,7 +397,7 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] assert.are.equal("crafted", result.resistanceSwapDescriptors[1].domain) assert.are.equal(2, result.resistanceSwapDescriptors[1].lineIndex) assert.is_truthy(result.item_string:find("{crafted}%+17%% to Fire Resistance")) - local parsedItem = new("Item", result.item_string) + local parsedItem = new("Item"):Item(result.item_string) assert.are.equal("+17% to Fire Resistance", parsedItem.explicitModLines[2].line) assert.is_true(parsedItem.explicitModLines[2].crafted) end) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index c03ea8b5cb0..a0bb3589c52 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -8,7 +8,7 @@ describe("TradeQuery", function() end) describe("cooperative result evaluation", function() it("resumes fetched result work over multiple frames", function() - local tradeQuery = new("TradeQuery", { itemsTab = {} }) + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) tradeQuery.controls.priceButton1 = { label = "Price Item" } tradeQuery.controls.pbNotice = { label = "" } tradeQuery.resultTbl[1] = { { }, { } } @@ -39,6 +39,86 @@ describe("TradeQuery", function() 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 @@ -172,6 +252,64 @@ describe("TradeQuery", function() assert.are.equal(itemString, tq.resultTbl[1][1].item_string) 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") + + 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) + + it("replaces capped candidates with the results of a pasted URL", function() + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tradeQuery.itemsTab.activeItemSet = {} + tradeQuery.itemsTab.slots = {} + tradeQuery.controls.pbNotice = { label = "" } + tradeQuery.slotTables[1] = { slotName = "Ring 1" } + local oldResult = { + item_string = "Rarity: RARE\nOld Hold\nGold Ring", + amount = 1, + currency = "chaos", + } + local newResult = { + item_string = "Rarity: RARE\nNew Hold\nGold Ring", + amount = 2, + currency = "chaos", + } + tradeQuery.unfilteredResultTbl[1] = { oldResult } + tradeQuery.resultTbl[1] = { oldResult } + tradeQuery.sortedResultTbl[1] = { { index = 1 } } + local searchCallback + tradeQuery.tradeQueryRequests.SearchWithURL = function(_, _, callback) + searchCallback = callback + end + tradeQuery:PriceItemRowDisplay(1, nil, 0, 20) + tradeQuery.controls.uri1.buf = "https://www.pathofexile.com/trade/search/pc/example" + + tradeQuery.controls.priceButton1.onClick() + searchCallback({ newResult }, nil, "{}") + + assert.is_nil(tradeQuery.unfilteredResultTbl[1]) + assert.are.equal(newResult.item_string, tradeQuery.resultTbl[1][1].item_string) + end) + end) describe("ReduceOutput", function() it("preserves lower-is-better values for weighted result comparison", function() local weights = { @@ -302,7 +440,7 @@ describe("TradeQuery", function() end local function newEvaluationQuery(lines, descriptors, enabled, capsRequired) - local tq = new("TradeQuery", { itemsTab = {} }) + local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) tq.tradeQueryGenerator = mock_queryGen tq.slotTables[1] = { slotName = "Ring 1" } tq.statSortSelectionList = { { stat = "Life", weightMult = 1 } } @@ -585,7 +723,7 @@ describe("TradeQuery", function() end) it("sorts retained capped and uncapped results by requested stat value", function() - local tq = new("TradeQuery", { itemsTab = {} }) + local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) tq.resultTbl[1] = { { id = "uncapped", resistanceCapsRequired = true }, { id = "capped", resistanceCapsRequired = true }, diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index ce735372a68..713709bf3e2 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -181,6 +181,9 @@ function TradeQueryClass:TradeQuery(itemsTab) 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 @@ -566,7 +569,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:StartResultEvaluation(row_idx) + if not self.resultFetchContexts[row_idx] then + self:StartResultEvaluation(row_idx) + end end end) self.controls.itemSortSelection.tooltipText = @@ -871,7 +876,9 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) self.statSortSelectionList = statSortSelectionList end for row_idx in pairs(self.resultTbl) do - self:StartResultEvaluation(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() @@ -920,8 +927,54 @@ function TradeQueryClass:CancelResultEvaluation(rowIdx) 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.unfilteredResultTbl[rowIdx] or 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, } @@ -1170,7 +1223,7 @@ function TradeQueryClass:UpdateDropdownList(row_idx) self.controls["resultDropdown".. row_idx]:SetList(dropdownLabels) end function TradeQueryClass:ResetResultRow(rowIdx) - self:CancelResultEvaluation(rowIdx) + self:CancelResultFetch(rowIdx) self.itemIndexTbl[rowIdx] = nil self.sortedResultTbl[rowIdx] = nil self.resultTbl[rowIdx] = nil @@ -1375,7 +1428,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:CancelResultEvaluation(row_idx) + self:CancelResultFetch(row_idx) self.tradeQueryGenerator:RequestQuery(activeSlot, { slotTbl = slotTbl, controls = controls, row_idx = row_idx }, self.statSortSelectionList, function(context, query, errMsg, queryOptions) if errMsg then self:SetNotice(context.controls.pbNotice, colorCodes.NEGATIVE .. errMsg) @@ -1389,13 +1442,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:SearchGeneratedQuery(queryOptions, 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, "") @@ -1430,6 +1485,9 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro 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 @@ -1485,13 +1543,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() - self:CancelResultEvaluation(row_idx) - 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 @@ -1499,18 +1559,16 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite self.lastQueries[row_idx] = query local selectedSlot = getSelectedSlot() local itemsSafe = self:FilterToSafeItems(items, selectedSlot and selectedSlot.slotName) + self.unfilteredResultTbl[row_idx] = nil self.resultTbl[row_idx] = itemsSafe self:StartResultEvaluation(row_idx) end - if errMsg then - controls["priceButton"..row_idx].label = "Price Item" - end 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 @@ -1573,7 +1631,7 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite if not evaluation or not evaluation.theoreticalResistanceSwapItemString or not self:IsResistanceSwapPreviewActive() then return end - local previewItem = new("Item", evaluation.theoreticalResistanceSwapItemString) + local previewItem = new("Item"):Item(evaluation.theoreticalResistanceSwapItemString) local previewTooltip = tooltip.resistanceSwapPreviewTooltip or new("Tooltip") tooltip.resistanceSwapPreviewTooltip = previewTooltip previewTooltip:Clear() @@ -1614,8 +1672,17 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite tooltip:AddSeparator(10) tooltip:AddLine(16, string.format("^7Price: %s %s", result.amount, result.currency)) end + local function getSelectedResult() + local resultIndex = self.itemIndexTbl[row_idx] + local resultRow = self.resultTbl[row_idx] + return resultIndex and resultRow and resultRow[resultIndex] + end controls["importButton"..row_idx] = new("ButtonControl"):ButtonControl({ "TOPLEFT", controls["resultDropdown"..row_idx], "TOPRIGHT"}, {8, 0, 100, row_height}, "Import Item", function() - self.itemsTab:CreateDisplayItemFromRaw(self.resultTbl[row_idx][self.itemIndexTbl[row_idx]].item_string) + local selectedResult = getSelectedResult() + if not selectedResult or not selectedResult.item_string then + return + end + self.itemsTab:CreateDisplayItemFromRaw(selectedResult.item_string) local item = self.itemsTab.displayItem -- pass "true" to not auto equip it as we will have our own logic self.itemsTab:AddDisplayItem(true) @@ -1631,21 +1698,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 @@ -1661,7 +1730,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 @@ -1677,7 +1749,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 caf9595fea5..a54d8a078bd 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1335,12 +1335,12 @@ Remove: %s will be removed from the search results.]], term, term, term) updateLastAnchor(controls.maxLevel) if not context.slotTbl.unique then - controls.groupResists = new("CheckBoxControl", { "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 18 }, "Resistance swaps:", function(state) end) + controls.groupResists = new("CheckBoxControl"):CheckBoxControl({ "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 18 }, "Resistance swaps:", function(state) end) controls.groupResists.state = self.lastGroupResists == true controls.groupResists.tooltipText = "Searches Fire, Cold, and Lightning Resistance as one total.\nResults are sorted using the best estimated swap; rolls may change." updateLastAnchor(controls.groupResists) - controls.includeResistCaps = new("CheckBoxControl", { "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 18 }, "Resistance caps:", function(state) end) + controls.includeResistCaps = new("CheckBoxControl"):CheckBoxControl({ "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 18 }, "Resistance caps:", function(state) end) controls.includeResistCaps.state = self.lastIncludeResistCaps == true controls.includeResistCaps.tooltipText = "Targets the current Elemental and Chaos Resistance caps when searching and evaluating items.\nItems that still miss a cap remain visible, and the selected result sort is unchanged." updateLastAnchor(controls.includeResistCaps) diff --git a/src/Classes/TradeResistanceSwap.lua b/src/Classes/TradeResistanceSwap.lua index a2777a11f85..f5951377bad 100644 --- a/src/Classes/TradeResistanceSwap.lua +++ b/src/Classes/TradeResistanceSwap.lua @@ -199,7 +199,7 @@ function M.validateItem(item, descriptors) end function M.buildVariant(itemString, descriptors, assignment) - local item = new("Item", itemString) + local item = new("Item"):Item(itemString) if not M.validateItem(item, descriptors) then return end From af829b4a5f9c2f0bf7915818820777cd6988b5fc Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Wed, 12 Aug 2026 22:33:00 +0200 Subject: [PATCH 7/8] Migrate resistance swap tooltip construction Construct tooltip instances explicitly after the upstream class-constructor migration, including the Ctrl preview and its focused tests. --- spec/System/TestTradeQuery_spec.lua | 6 +++--- src/Classes/TradeQuery.lua | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index a0bb3589c52..a142ac96eea 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -194,7 +194,7 @@ describe("TradeQuery", function() }) tq.itemsTab.AddItemTooltip = function() end local dropdown = buildRow1Dropdown(tq) - local tooltip = new("Tooltip") + local tooltip = new("Tooltip"):Tooltip() dropdown.tooltipFunc(tooltip, "DROP", 1, nil) local text = "" @@ -235,7 +235,7 @@ describe("TradeQuery", function() end tq.IsResistanceSwapPreviewActive = function() return true end local dropdown = buildRow1Dropdown(tq) - local tooltip = new("Tooltip") + local tooltip = new("Tooltip"):Tooltip() dropdown.tooltipFunc(tooltip, "DROP", 1, nil) @@ -266,7 +266,7 @@ describe("TradeQuery", function() tradeQuery.sortedResultTbl[1] = { { index = 1 } } tradeQuery:PriceItemRowDisplay(1, nil, 0, 20) tradeQuery.itemIndexTbl[1] = 2 - local tooltip = new("Tooltip") + local tooltip = new("Tooltip"):Tooltip() assert.has_no.errors(function() tradeQuery.controls.importButton1.tooltipFunc(tooltip) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 713709bf3e2..d15ca95f46c 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -1632,7 +1632,7 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite return end local previewItem = new("Item"):Item(evaluation.theoreticalResistanceSwapItemString) - local previewTooltip = tooltip.resistanceSwapPreviewTooltip or new("Tooltip") + local previewTooltip = tooltip.resistanceSwapPreviewTooltip or new("Tooltip"):Tooltip() tooltip.resistanceSwapPreviewTooltip = previewTooltip previewTooltip:Clear() self.itemsTab:AddItemTooltip(previewTooltip, previewItem, tooltipSlot) From 8fc3682f6b6b2caabc9dd5a75066a7564a42e74a Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 18 Aug 2026 22:08:19 +0200 Subject: [PATCH 8/8] Clarify resistance swap naming and contracts Align option, cache, and evaluation names with their actual behavior. Remove duplicated result state and descriptor fields that had no production consumer. --- spec/System/TestTradeQueryGenerator_spec.lua | 40 ++--- spec/System/TestTradeQueryRequests_spec.lua | 2 - spec/System/TestTradeQuery_spec.lua | 92 ++++++------ src/Classes/TradeQuery.lua | 150 +++++++++---------- src/Classes/TradeQueryGenerator.lua | 32 ++-- src/Classes/TradeQueryRequests.lua | 2 + src/Classes/TradeResistanceGrouping.lua | 9 +- src/Classes/TradeResistanceSwap.lua | 22 +-- 8 files changed, 171 insertions(+), 178 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 6a3793c398a..fa8e31f6361 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -154,10 +154,10 @@ describe("TradeQueryGenerator", function() end) end) - describe("resistance pseudo-stat grouping", function() + describe("resistance search options", function() it("derives non-negative cap shortfalls from the blank-item output", function() assert.are.same({ Fire = 12, Cold = 0, Lightning = 34, Chaos = 56 }, - tradeResistanceGrouping.getResistanceCapShortfall({ + tradeResistanceGrouping.getResistanceCapShortfallByType({ MissingFireResist = 12, MissingColdResist = -3, MissingLightningResist = 34, @@ -211,11 +211,11 @@ describe("TradeQueryGenerator", function() influence1 = 1, influence2 = 1, statWeights = { { stat = "Life", weightMult = 1 } }, - groupResists = options.groupResists, + includeResistSwaps = options.includeResistSwaps, includeResistCaps = options.includeResistCaps, }, requiredMods = options.requiredMods or {}, - resistCapShortfall = options.resistCapShortfall, + resistanceCapShortfallByType = options.resistanceCapShortfallByType, } queryGen.requesterContext = { slotTbl = { sentinel = true } } local queryJson @@ -240,7 +240,7 @@ describe("TradeQueryGenerator", function() end it("groups resistance without changing damage filters", function() - local query = finishQuery({ groupResists = true }, { + local query = finishQuery({ includeResistSwaps = true }, { annotatedWeight("explicit.fire_resistance", "+#% to Fire Resistance", 10, 10), { tradeModId = "explicit.fire_damage", weight = 8, meanStatDiff = 8, invert = false }, { tradeModId = "explicit.life", weight = 6, meanStatDiff = 6, invert = false }, @@ -257,7 +257,7 @@ describe("TradeQueryGenerator", function() end) it("leaves hybrid elemental and chaos resistance as its only original filter", function() - local query = finishQuery({ groupResists = true }, { + local query = finishQuery({ includeResistSwaps = true }, { annotatedWeight("explicit.hybrid_resistance", "+#% to Fire and Chaos Resistances", 10, 10), }) local filters = query.query.stats[1].filters @@ -267,7 +267,7 @@ describe("TradeQueryGenerator", function() end) it("leaves implicit elemental resistance as its original filter", function() - local query = finishQuery({ groupResists = true }, { + local query = finishQuery({ includeResistSwaps = true }, { annotatedWeight("implicit.fire_resistance", "+#% to Fire Resistance", 10, 10), }) local filters = query.query.stats[1].filters @@ -290,7 +290,7 @@ describe("TradeQueryGenerator", function() end table.insert(weights, { tradeModId = "explicit.low_priority_filter", weight = 1, meanStatDiff = 1, invert = false }) - local query = finishQuery({ groupResists = true }, weights) + local query = finishQuery({ includeResistSwaps = true }, weights) local ids = {} for _, filter in ipairs(query.query.stats[1].filters) do ids[filter.id] = true @@ -301,17 +301,17 @@ describe("TradeQueryGenerator", function() assert.is_true(ids["explicit.low_priority_filter"]) end) - it("does not persist the grouping option into requester context", function() - local _, slotTable, queryOptions = finishQuery({ groupResists = true }, { + it("does not persist the swap option into requester context", function() + local _, slotTable, queryOptions = finishQuery({ includeResistSwaps = true }, { { tradeModId = "explicit.life", weight = 6, meanStatDiff = 6, invert = false }, }) assert.are.same({ sentinel = true }, slotTable) - assert.are.same({ groupResists = true, includeResistCaps = false, weightAdjustedSearch = true }, queryOptions) + assert.are.same({ includeResistSwaps = true, includeResistCaps = false, weightAdjustedSearch = true }, queryOptions) end) it("normalises multi-element resistance weights before pseudo grouping", function() - local query = finishQuery({ groupResists = true }, { + local query = finishQuery({ includeResistSwaps = true }, { annotatedWeight("explicit.all_resistance", "+#% to all Elemental Resistances", 30, 30), annotatedWeight("explicit.fire_resistance", "+#% to Fire Resistance", 8, 8), }) @@ -324,7 +324,7 @@ describe("TradeQueryGenerator", function() it("moves individual resistance shortfalls into AND filters and removes resistance weights", function() local query, _, queryOptions = finishQuery({ includeResistCaps = true, - resistCapShortfall = { Fire = 10, Cold = 20, Lightning = 30, Chaos = 40 }, + resistanceCapShortfallByType = { Fire = 10, Cold = 20, Lightning = 30, Chaos = 40 }, }, { annotatedWeight("explicit.fire_resistance", "+#% to Fire Resistance", 10, 10), annotatedWeight("implicit.cold_resistance", "+#% to Cold Resistance", 9, 9), @@ -357,9 +357,9 @@ describe("TradeQueryGenerator", function() it("combines elemental shortfalls when caps and swaps are enabled", function() local query = finishQuery({ - groupResists = true, + includeResistSwaps = true, includeResistCaps = true, - resistCapShortfall = { Fire = 10, Cold = 20, Lightning = 30, Chaos = 40 }, + resistanceCapShortfallByType = { Fire = 10, Cold = 20, Lightning = 30, Chaos = 40 }, }, { annotatedWeight("explicit.fire_resistance", "+#% to Fire Resistance", 10, 10), { tradeModId = "explicit.life", weight = 6, meanStatDiff = 6, invert = false }, @@ -383,7 +383,7 @@ describe("TradeQueryGenerator", function() it("builds an AND-only price-sorted query when caps remove every weighted filter", function() local query, _, queryOptions = finishQuery({ includeResistCaps = true, - resistCapShortfall = { Fire = 25 }, + resistanceCapShortfallByType = { Fire = 25 }, }, { annotatedWeight("explicit.fire_resistance", "+#% to Fire Resistance", 10, 10), }) @@ -397,7 +397,7 @@ describe("TradeQueryGenerator", function() it("does not add zero resistance minimums or an empty AND group", function() local query, _, _, queryError = finishQuery({ includeResistCaps = true, - resistCapShortfall = { Fire = 0, Cold = 0, Lightning = 0, Chaos = 0 }, + resistanceCapShortfallByType = { Fire = 0, Cold = 0, Lightning = 0, Chaos = 0 }, }, { annotatedWeight("explicit.fire_resistance", "+#% to Fire Resistance", 10, 10), }) @@ -426,7 +426,7 @@ describe("TradeQueryGenerator", function() end local query, _, queryOptions = finishQuery({ includeResistCaps = true, - resistCapShortfall = { Fire = 25 }, + resistanceCapShortfallByType = { Fire = 25 }, requiredMods = requiredMods, }, { { tradeModId = "explicit.life", weight = 6, meanStatDiff = 6, invert = false }, @@ -440,8 +440,8 @@ describe("TradeQueryGenerator", function() assert.is_false(queryOptions.weightAdjustedSearch) end) - it("preserves upstream filter order when resistance grouping is disabled", function() - local query = finishQuery({ groupResists = false }, { + it("preserves upstream filter order when resistance swaps are disabled", function() + local query = finishQuery({ includeResistSwaps = false }, { annotatedWeight("explicit.fire_resistance", "+#% to Fire Resistance", 3, 30), { tradeModId = "explicit.fire_damage", weight = 2, meanStatDiff = 20, invert = false }, { tradeModId = "explicit.life", weight = 1, meanStatDiff = 10, invert = false }, diff --git a/spec/System/TestTradeQueryRequests_spec.lua b/spec/System/TestTradeQueryRequests_spec.lua index 271b787b2a4..8ea1c6c0a5d 100644 --- a/spec/System/TestTradeQueryRequests_spec.lua +++ b/spec/System/TestTradeQueryRequests_spec.lua @@ -306,8 +306,6 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] lineIndex = 1, element = "Fire", domain = "explicit", - tier = "S7", - range = { min = 12, max = 17 }, }, }, result.resistanceSwapDescriptors) assert.is_nil(result.explicitMods) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index a142ac96eea..b80ab615d46 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -74,10 +74,10 @@ describe("TradeQuery", function() evaluated = true end - local fetchContext = tradeQuery:StartResultFetch(1) + local fetchToken = tradeQuery:StartResultFetch(1) tradeQuery:StartResultEvaluation(1) - assert.is_true(tradeQuery:IsResultFetchCurrent(1, fetchContext)) + assert.is_true(tradeQuery:IsResultFetchCurrent(1, fetchToken)) assert.is_nil(tradeQuery.resultEvaluationContexts[1]) assert.is_false(evaluated) assert.are.equal("Searching...", tradeQuery.controls.priceButton1.label) @@ -185,9 +185,9 @@ describe("TradeQuery", function() evaluation = { { output = {}, weight = 1, - theoreticalResistanceSwap = { { from = "Fire", to = "Cold", value = 17 } }, - theoreticalResistanceSwapItemString = "Rarity: RARE\nBehemoth Hold\nCoral Ring\nImplicits: 0\n+17% to Cold Resistance", - theoreticalResistanceSwapLineIndexes = { 1 }, + estimatedResistanceSwaps = { { from = "Fire", to = "Cold" } }, + estimatedResistanceSwapItemString = "Rarity: RARE\nBehemoth Hold\nCoral Ring\nImplicits: 0\n+17% to Cold Resistance", + estimatedResistanceSwapLineIndexes = { 1 }, } }, } } }, sortedResultTbl = { [1] = { { index = 1 } } }, @@ -218,12 +218,12 @@ describe("TradeQuery", function() evaluation = { { output = {}, weight = 1, - theoreticalResistanceSwap = { - { from = "Fire", to = "Cold", value = 17 }, - { from = "Cold", to = "Lightning", value = 24 }, + estimatedResistanceSwaps = { + { from = "Fire", to = "Cold" }, + { from = "Cold", to = "Lightning" }, }, - theoreticalResistanceSwapItemString = "Rarity: RARE\nBehemoth Hold\nCoral Ring\nImplicits: 0\n+30 to Strength\n+17% to Cold Resistance\n+24% to Lightning Resistance", - theoreticalResistanceSwapLineIndexes = { 2, 3 }, + estimatedResistanceSwapItemString = "Rarity: RARE\nBehemoth Hold\nCoral Ring\nImplicits: 0\n+30 to Strength\n+17% to Cold Resistance\n+24% to Lightning Resistance", + estimatedResistanceSwapLineIndexes = { 2, 3 }, } }, } } }, sortedResultTbl = { [1] = { { index = 1 } } }, @@ -277,7 +277,7 @@ describe("TradeQuery", function() end) end) - it("replaces capped candidates with the results of a pasted URL", function() + it("replaces fetched candidates with the results of a pasted URL", function() local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) tradeQuery.itemsTab.activeItemSet = {} tradeQuery.itemsTab.slots = {} @@ -293,7 +293,6 @@ describe("TradeQuery", function() amount = 2, currency = "chaos", } - tradeQuery.unfilteredResultTbl[1] = { oldResult } tradeQuery.resultTbl[1] = { oldResult } tradeQuery.sortedResultTbl[1] = { { index = 1 } } local searchCallback @@ -306,7 +305,6 @@ describe("TradeQuery", function() tradeQuery.controls.priceButton1.onClick() searchCallback({ newResult }, nil, "{}") - assert.is_nil(tradeQuery.unfilteredResultTbl[1]) assert.are.equal(newResult.item_string, tradeQuery.resultTbl[1][1].item_string) end) end) @@ -434,12 +432,10 @@ describe("TradeQuery", function() lineIndex = lineIndex, element = element, domain = domain or "explicit", - tier = "S1", - range = { min = 1, max = 48 }, } end - local function newEvaluationQuery(lines, descriptors, enabled, capsRequired) + local function newEvaluationQuery(lines, descriptors, enabled, prioritiseCaps) local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) tq.tradeQueryGenerator = mock_queryGen tq.slotTables[1] = { slotName = "Ring 1" } @@ -448,7 +444,7 @@ describe("TradeQuery", function() item_string = itemString(lines), resistanceSwapDescriptors = descriptors, resistanceSwapEnabled = enabled, - resistanceCapsRequired = capsRequired, + prioritiseResistanceCaps = prioritiseCaps, } } return tq end @@ -496,7 +492,7 @@ describe("TradeQuery", function() { "+10% to Fire Resistance", "+20% to Cold Resistance" }, { descriptor(1, "Fire"), descriptor(2, "Cold") }, true, true) local calls = 0 - tq.HasResistanceSwapOutputDependency = function() return false end + tq.ResistanceSwapMayAffectOutput = function() return false end local evaluation = tq:GetResultEvaluation(1, 1, function() calls = calls + 1 @@ -511,14 +507,14 @@ describe("TradeQuery", function() assert.are.equal(1, calls) assert.are.equal(1, #evaluation) - assert.is_nil(evaluation[1].theoreticalResistanceSwap) + assert.is_nil(evaluation[1].estimatedResistanceSwaps) end) it("only evaluates swaps that can feed an elemental resistance deficit", function() local tq = newEvaluationQuery( { "+10% to Fire Resistance" }, { descriptor(1, "Fire") }, true, true) local calls = 0 - tq.HasResistanceSwapOutputDependency = function() return false end + tq.ResistanceSwapMayAffectOutput = function() return false end local evaluation = tq:GetResultEvaluation(1, 1, function(args) calls = calls + 1 @@ -528,7 +524,7 @@ describe("TradeQuery", function() assert.are.equal(2, calls) assert.are.equal(1, #evaluation) - assert.are.equal("Cold", evaluation[1].theoreticalResistanceSwap[1].to) + assert.are.equal("Cold", evaluation[1].estimatedResistanceSwaps[1].to) end) it("keeps resistance swaps when the build depends on resistance state", function() @@ -538,7 +534,7 @@ describe("TradeQuery", function() local calls = 0 local calc = scoreAndCapsFromElements({ Fire = 0, Cold = 0, Lightning = 0, Chaos = 0 }, { Fire = 1, Cold = 2, Lightning = 3 }) - tq.HasResistanceSwapOutputDependency = function() return true end + tq.ResistanceSwapMayAffectOutput = function() return true end tq:GetResultEvaluation(1, 1, function(args) calls = calls + 1 @@ -554,18 +550,18 @@ describe("TradeQuery", function() modDB = { mods = { } }, } } } } - assert.is_false(tq:HasResistanceSwapOutputDependency()) - assert.is_true(tq:HasResistanceSwapOutputDependency({ modList = { { + assert.is_false(tq:ResistanceSwapMayAffectOutput()) + assert.is_true(tq:ResistanceSwapMayAffectOutput({ modList = { { name = "FirePenIncreasedByUncappedFireRes", type = "FLAG", } } })) - assert.is_true(tq:HasResistanceSwapOutputDependency({ modList = { { + assert.is_true(tq:ResistanceSwapMayAffectOutput({ modList = { { name = "DamageIncreasedByOvercappedColdRes", type = "FLAG", } } })) tq.statSortSelectionList = { { stat = "FireResistTotal", weightMult = 1 } } - assert.is_true(tq:HasResistanceSwapOutputDependency()) + assert.is_true(tq:ResistanceSwapMayAffectOutput()) tq.statSortSelectionList = { { stat = "Life", weightMult = 1 } } tq.itemsTab.build.calcsTab.mainEnv.player.modDB.mods.LifeRegen = { { @@ -573,7 +569,7 @@ describe("TradeQuery", function() type = "BASE", [1] = { type = "PerStat", stat = "FireResistTotal" }, } } - assert.is_true(tq:HasResistanceSwapOutputDependency()) + assert.is_true(tq:ResistanceSwapMayAffectOutput()) tq.itemsTab.build.calcsTab.mainEnv.player.modDB.mods = { FirePenIncreasedByUncappedFireRes = { { @@ -581,7 +577,7 @@ describe("TradeQuery", function() type = "FLAG", } }, } - assert.is_true(tq:HasResistanceSwapOutputDependency()) + assert.is_true(tq:ResistanceSwapMayAffectOutput()) end) it("evaluates exactly 3, 6, and 6 distinct-target assignments for one to three candidates", function() @@ -638,18 +634,18 @@ describe("TradeQuery", function() local evaluation = tq:GetResultEvaluation(1, 1, scoreFromElements({ Fire = 1, Cold = 2, Lightning = 4 }), { Life = 100 }) - local swaps = evaluation[1].theoreticalResistanceSwap + local swaps = evaluation[1].estimatedResistanceSwaps assert.are.equal(2, #swaps) - assert.are.same({ from = "Fire", to = "Cold", value = 10 }, swaps[1]) - assert.are.same({ from = "Cold", to = "Lightning", value = 20 }, swaps[2]) - assert.are.same({ 1, 2 }, evaluation[1].theoreticalResistanceSwapLineIndexes) - assert.is_truthy(evaluation[1].theoreticalResistanceSwapItemString:find("+10%% to Cold Resistance")) - assert.is_truthy(evaluation[1].theoreticalResistanceSwapItemString:find("+20%% to Lightning Resistance")) + assert.are.same({ from = "Fire", to = "Cold" }, swaps[1]) + assert.are.same({ from = "Cold", to = "Lightning" }, swaps[2]) + assert.are.same({ 1, 2 }, evaluation[1].estimatedResistanceSwapLineIndexes) + assert.is_truthy(evaluation[1].estimatedResistanceSwapItemString:find("+10%% to Cold Resistance")) + assert.is_truthy(evaluation[1].estimatedResistanceSwapItemString:find("+20%% to Lightning Resistance")) assert.are.equal(original, tq.resultTbl[1][1].item_string) end) - it("prefers fewer swaps when theoretical weights tie", function() + it("prefers fewer swaps when evaluated weights tie", function() local tq = newEvaluationQuery( { "+10% to Cold Resistance" }, { descriptor(1, "Cold") }, true) @@ -657,7 +653,7 @@ describe("TradeQuery", function() return { Life = 100 } end, { Life = 100 }) - assert.is_nil(evaluation[1].theoreticalResistanceSwap) + assert.is_nil(evaluation[1].estimatedResistanceSwaps) end) it("uses one baseline calculation when ranking is disabled or ineligible", function() @@ -676,7 +672,7 @@ describe("TradeQuery", function() end end) - it("keeps only swap permutations that actually reach every resistance cap", function() + it("keeps the listed item when it already meets every resistance cap", function() local tq = newEvaluationQuery( { "+40% to Fire Resistance", "+80% to Cold Resistance", "+30% to Chaos Resistance" }, { descriptor(1, "Fire"), descriptor(2, "Cold") }, true, true) @@ -685,7 +681,7 @@ describe("TradeQuery", function() { Fire = 1, Cold = 1, Lightning = 100 }), { Life = 100 }) assert.are.equal(1, #evaluation) - assert.is_nil(evaluation[1].theoreticalResistanceSwap) + assert.is_nil(evaluation[1].estimatedResistanceSwaps) end) it("retains the best partial assignment when the elemental total cannot reach every cap", function() @@ -696,7 +692,7 @@ describe("TradeQuery", function() { Fire = 40, Cold = 40, Lightning = 0, Chaos = 30 }), { Life = 100 }) assert.are.equal(1, #evaluation) - assert.are.equal(40, evaluation[1].resistanceCapShortfall) + assert.are.equal(40, evaluation[1].totalResistanceCapShortfall) end) it("records cap shortfall without dropping items when swaps are disabled", function() @@ -709,7 +705,7 @@ describe("TradeQuery", function() assert.are.equal(1, #valid:GetResultEvaluation(1, 1, calc, { Life = 100 })) local invalidEvaluation = invalid:GetResultEvaluation(1, 1, calc, { Life = 100 }) assert.are.equal(1, #invalidEvaluation) - assert.are.equal(1, invalidEvaluation[1].resistanceCapShortfall) + assert.are.equal(1, invalidEvaluation[1].totalResistanceCapShortfall) end) it("retains an item that only misses the requested Chaos resistance", function() @@ -719,14 +715,14 @@ describe("TradeQuery", function() scoreAndCapsFromElements({ Fire = 40, Cold = 0, Lightning = 0, Chaos = 30 }), { Life = 100 }) assert.are.equal(1, #evaluation) - assert.are.equal(1, evaluation[1].resistanceCapShortfall) + assert.are.equal(1, evaluation[1].totalResistanceCapShortfall) end) it("sorts retained capped and uncapped results by requested stat value", function() local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) tq.resultTbl[1] = { - { id = "uncapped", resistanceCapsRequired = true }, - { id = "capped", resistanceCapsRequired = true }, + { id = "uncapped", prioritiseResistanceCaps = true }, + { id = "capped", prioritiseResistanceCaps = true }, { id = "unrestricted" }, } tq.sortModes = { StatValue = "statValue" } @@ -734,7 +730,7 @@ describe("TradeQuery", function() return function() return { } end, { } end } } tq.GetResultEvaluation = function(_, _, resultIndex) - return { { weight = 4 - resultIndex, resistanceCapShortfall = resultIndex == 1 and 10 or 0 } } + return { { weight = 4 - resultIndex, totalResistanceCapShortfall = resultIndex == 1 and 10 or 0 } } end local sorted = tq:SortFetchResults(1, tq.sortModes.StatValue) @@ -746,12 +742,10 @@ describe("TradeQuery", function() }) end) - it("revalidates and restores fetched results when the build resistance state changes", function() + it("recalculates cached cap shortfall when the build resistance state changes", function() local requiredFire = 50 local tq = newEvaluationQuery( { "+40% to Fire Resistance", "+30% to Chaos Resistance" }, nil, false, true) - local itemEntry = tq.resultTbl[1][1] - tq.unfilteredResultTbl[1] = { itemEntry } local function calc(args) return scoreAndCapsFromElements({ Fire = requiredFire, @@ -782,12 +776,12 @@ describe("TradeQuery", function() local first = tq:GetResultEvaluation(1, 1) assert.are.equal(1, #first) - assert.are.equal(10, first[1].resistanceCapShortfall) + assert.are.equal(10, first[1].totalResistanceCapShortfall) requiredFire = 40 local second = tq:GetResultEvaluation(1, 1) assert.are.equal(1, #second) - assert.are.equal(0, second[1].resistanceCapShortfall) + assert.are.equal(0, second[1].totalResistanceCapShortfall) end) it("reuses the single best evaluation while the build and weights are unchanged", function() diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index d15ca95f46c..7f98eec757b 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -30,7 +30,7 @@ local function meetsResistanceCaps(output) return true end -local function getResistanceCapShortfall(output) +local function getTotalResistanceCapShortfall(output) local shortfall = 0 for _, resistanceType in ipairs({ "Fire", "Cold", "Lightning", "Chaos" }) do local missing = output["Missing" .. resistanceType .. "Resist"] @@ -42,7 +42,7 @@ local function getResistanceCapShortfall(output) return shortfall end -local function getResistanceState(output) +local function getResistanceStateSnapshot(output) local state = {} for _, resistanceType in ipairs({ "Fire", "Cold", "Lightning", "Chaos" }) do for _, suffix in ipairs({ "Resist", "ResistTotal", "Missing" .. resistanceType .. "Resist" }) do @@ -67,7 +67,7 @@ local function getMissingElementalResistanceTargets(output) return targets end -local function assignmentCanRepairResistanceDeficit(descriptors, assignment, missingTargets) +local function assignmentTargetsMissingResistance(descriptors, assignment, missingTargets) if not missingTargets then return true end @@ -80,7 +80,7 @@ local function assignmentCanRepairResistanceDeficit(descriptors, assignment, mis return false end -local function isResistanceStateFlag(value) +local function isElementalResistanceStateFlag(value) return type(value) == "string" and (value:find("Uncapped", 1, true) or value:find("Overcapped", 1, true)) and (value:find("FireRes", 1, true) @@ -88,7 +88,7 @@ local function isResistanceStateFlag(value) or value:find("LightningRes", 1, true)) end -local function isResistanceStateStat(value) +local function isElementalResistanceStat(value) return type(value) == "string" and ( value:find("FireResist", 1, true) or value:find("ColdResist", 1, true) @@ -96,11 +96,11 @@ local function isResistanceStateStat(value) ) end -local function modUsesResistanceState(mod) +local function modUsesElementalResistanceState(mod) for _, tag in ipairs(mod or { }) do if type(tag) == "table" then for _, value in pairs(tag) do - if isResistanceStateStat(value) then + if isElementalResistanceStat(value) then return true end end @@ -109,30 +109,30 @@ local function modUsesResistanceState(mod) return false end -local function modStoreUsesResistanceState(store, visited) +local function modStoreUsesElementalResistanceState(store, visited) if type(store) ~= "table" or visited[store] then return false end visited[store] = true if store.mods then for name, modList in pairs(store.mods) do - if isResistanceStateFlag(name) then + if isElementalResistanceStateFlag(name) then return true end for _, mod in ipairs(modList) do - if modUsesResistanceState(mod) then + if modUsesElementalResistanceState(mod) then return true end end end else for _, mod in ipairs(store) do - if isResistanceStateFlag(mod.name) or modUsesResistanceState(mod) then + if isElementalResistanceStateFlag(mod.name) or modUsesElementalResistanceState(mod) then return true end end end - return modStoreUsesResistanceState(store.parent, visited) + return modStoreUsesElementalResistanceState(store.parent, visited) end ---@class TradeQuery @@ -145,12 +145,11 @@ function TradeQueryClass:TradeQuery(itemsTab) self.controls = { } -- table of price results index by slot and number of fetched results self.resultTbl = { } - self.unfilteredResultTbl = { } self.sortedResultTbl = { } self.itemIndexTbl = { } -- tooltip acceleration tables self.onlyWeightedBaseOutput = { } - self.resistanceBaseOutput = { } + self.resistanceStateCache = { } self.lastComparedWeightList = { } -- default set of trade item sort selection @@ -183,7 +182,7 @@ function TradeQueryClass:TradeQuery(itemsTab) self.resultEvaluationQueued = {} -- Identity tokens keep network fetches separate from result evaluation and -- prevent an older response from replacing a newer search. - self.resultFetchContexts = {} + self.resultFetchTokens = {} self.tradeQueryRequests = new("TradeQueryRequests"):TradeQueryRequests() if not main.api then @@ -569,7 +568,7 @@ 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 - if not self.resultFetchContexts[row_idx] then + if not self.resultFetchTokens[row_idx] then self:StartResultEvaluation(row_idx) end end @@ -876,7 +875,7 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) self.statSortSelectionList = statSortSelectionList end for row_idx in pairs(self.resultTbl) do - if not self.resultFetchContexts[row_idx] then + if not self.resultFetchTokens[row_idx] then self:StartResultEvaluation(row_idx) end end @@ -929,24 +928,24 @@ end function TradeQueryClass:StartResultFetch(rowIdx) self:CancelResultEvaluation(rowIdx) - local context = { } - self.resultFetchContexts[rowIdx] = context + local fetchToken = { } + self.resultFetchTokens[rowIdx] = fetchToken local button = self.controls["priceButton" .. rowIdx] if button then button.label = "Searching..." end - return context + return fetchToken end -function TradeQueryClass:IsResultFetchCurrent(rowIdx, context) - return self.resultFetchContexts[rowIdx] == context +function TradeQueryClass:IsResultFetchCurrent(rowIdx, fetchToken) + return self.resultFetchTokens[rowIdx] == fetchToken end -function TradeQueryClass:FinishResultFetch(rowIdx, context) - if not self:IsResultFetchCurrent(rowIdx, context) then +function TradeQueryClass:FinishResultFetch(rowIdx, fetchToken) + if not self:IsResultFetchCurrent(rowIdx, fetchToken) then return false end - self.resultFetchContexts[rowIdx] = nil + self.resultFetchTokens[rowIdx] = nil local button = self.controls["priceButton" .. rowIdx] if button then button.label = "Price Item" @@ -955,15 +954,15 @@ function TradeQueryClass:FinishResultFetch(rowIdx, context) end function TradeQueryClass:CancelResultFetch(rowIdx) - self.resultFetchContexts[rowIdx] = nil + self.resultFetchTokens[rowIdx] = nil self:CancelResultEvaluation(rowIdx) end function TradeQueryClass:StartResultEvaluation(rowIdx) - if self.resultFetchContexts[rowIdx] then + if self.resultFetchTokens[rowIdx] then return end - local results = self.unfilteredResultTbl[rowIdx] or self.resultTbl[rowIdx] or { } + local results = self.resultTbl[rowIdx] or { } self.itemIndexTbl[rowIdx] = nil self.sortedResultTbl[rowIdx] = nil self.totalPrice[rowIdx] = nil @@ -1055,19 +1054,21 @@ function TradeQueryClass:ReduceOutput(output) return smallOutput end -function TradeQueryClass:HasResistanceSwapOutputDependency(item) +-- True includes any uncertainty; false proves that elemental resistance swaps +-- cannot affect the selected outputs, so cap-only pruning is safe. +function TradeQueryClass:ResistanceSwapMayAffectOutput(item) for _, stat in ipairs(self.statSortSelectionList or { }) do - if isResistanceStateStat(stat.stat) then + if isElementalResistanceStat(stat.stat) then return true end end local visited = { } - if item and (modStoreUsesResistanceState(item.modList, visited) - or modStoreUsesResistanceState(item.baseModList, visited)) then + if item and (modStoreUsesElementalResistanceState(item.modList, visited) + or modStoreUsesElementalResistanceState(item.baseModList, visited)) then return true end for _, modList in pairs(item and item.slotModList or { }) do - if modStoreUsesResistanceState(modList, visited) then + if modStoreUsesElementalResistanceState(modList, visited) then return true end end @@ -1078,19 +1079,20 @@ function TradeQueryClass:HasResistanceSwapOutputDependency(item) -- Without the calculated modifier graph, the resistance state cannot be proven irrelevant. return true end - if modStoreUsesResistanceState(player.modDB, visited) then + if modStoreUsesElementalResistanceState(player.modDB, visited) then return true end for _, activeSkill in ipairs(player.activeSkillList or { }) do - if modStoreUsesResistanceState(activeSkill.skillModList, visited) - or modStoreUsesResistanceState(activeSkill.modList, visited) then + if modStoreUsesElementalResistanceState(activeSkill.skillModList, visited) + or modStoreUsesElementalResistanceState(activeSkill.modList, visited) then return true end end return false end --- Method to evaluate a result by getting it's output and weight +-- Cache the best eligible item variant while the compared build outputs, +-- selected weights, and resistance state remain unchanged. function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, baseOutput, yieldFunc) local result = self.resultTbl[row_idx][result_index] if not calcFunc then @@ -1103,20 +1105,18 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba if not self.lastComparedWeightList[row_idx] then self.lastComparedWeightList[row_idx] = { } end - if not self.resistanceBaseOutput[row_idx] then - self.resistanceBaseOutput[row_idx] = { } + if not self.resistanceStateCache[row_idx] then + self.resistanceStateCache[row_idx] = { } end - local resistanceBaseOutput = result.resistanceCapsRequired and getResistanceState(baseOutput) - -- A shared calculator is an optimization, not a cache bypass. Reuse the result - -- whenever the build outputs, selected weights, and resistance state still match. + local resistanceStateSnapshot = result.prioritiseResistanceCaps and getResistanceStateSnapshot(baseOutput) if result.evaluation and tableDeepEquals(onlyWeightedBaseOutput, self.onlyWeightedBaseOutput[row_idx][result_index]) and tableDeepEquals(self.statSortSelectionList, self.lastComparedWeightList[row_idx][result_index]) - and (not result.resistanceCapsRequired or tableDeepEquals(resistanceBaseOutput, self.resistanceBaseOutput[row_idx][result_index])) then + and (not result.prioritiseResistanceCaps or tableDeepEquals(resistanceStateSnapshot, self.resistanceStateCache[row_idx][result_index])) then return result.evaluation end self.onlyWeightedBaseOutput[row_idx][result_index] = onlyWeightedBaseOutput self.lastComparedWeightList[row_idx][result_index] = self.statSortSelectionList - self.resistanceBaseOutput[row_idx][result_index] = resistanceBaseOutput + self.resistanceStateCache[row_idx][result_index] = resistanceStateSnapshot local slotTbl = self.slotTables[row_idx] local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId local slotName = jewelNodeId and "Jewel " .. tostring(jewelNodeId) or slotTbl.slotName @@ -1151,7 +1151,7 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba else local item = new("Item"):Item(result.item_string) local descriptors = result.resistanceSwapEnabled and result.resistanceSwapDescriptors - local assignments = descriptors and tradeResistanceSwap.validateItem(item, descriptors) + local assignments = descriptors and tradeResistanceSwap.itemMatchesSwapDescriptors(item, descriptors) and tradeResistanceSwap.getAssignments(descriptors) or {} local bestEvaluation local bestSwapCount @@ -1164,38 +1164,38 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba return { output = output, weight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, output, self.statSortSelectionList), - resistanceCapShortfall = result.resistanceCapsRequired - and getResistanceCapShortfall(fullOutput) or 0, + totalResistanceCapShortfall = result.prioritiseResistanceCaps + and getTotalResistanceCapShortfall(fullOutput) or 0, }, fullOutput end local listedEvaluation, listedFullOutput = evaluateVariant(item) bestEvaluation = listedEvaluation bestSwapCount = 0 local function isBetterEvaluation(evaluation, swapCount) - if result.resistanceCapsRequired - and evaluation.resistanceCapShortfall ~= bestEvaluation.resistanceCapShortfall then - return evaluation.resistanceCapShortfall < bestEvaluation.resistanceCapShortfall + if result.prioritiseResistanceCaps + and evaluation.totalResistanceCapShortfall ~= bestEvaluation.totalResistanceCapShortfall then + return evaluation.totalResistanceCapShortfall < bestEvaluation.totalResistanceCapShortfall end return evaluation.weight > bestEvaluation.weight or evaluation.weight == bestEvaluation.weight and swapCount < bestSwapCount end - local resistanceStateIndependent = result.resistanceCapsRequired - and not self:HasResistanceSwapOutputDependency(item) - local skipSwaps = resistanceStateIndependent and meetsResistanceCaps(listedFullOutput) - local missingTargets = resistanceStateIndependent + local canPruneSwapsByResistanceCaps = result.prioritiseResistanceCaps + and not self:ResistanceSwapMayAffectOutput(item) + local skipSwaps = canPruneSwapsByResistanceCaps and meetsResistanceCaps(listedFullOutput) + local missingTargets = canPruneSwapsByResistanceCaps and getMissingElementalResistanceTargets(listedFullOutput) or nil for _, assignment in ipairs(assignments) do if assignment.swaps > 0 and not skipSwaps - and assignmentCanRepairResistanceDeficit(descriptors, assignment, missingTargets) then + and assignmentTargetsMissingResistance(descriptors, assignment, missingTargets) then local variant, swaps, swappedLineIndexes = tradeResistanceSwap.buildVariant(result.item_string, descriptors, assignment) if variant then local evaluation = evaluateVariant(variant) if evaluation and isBetterEvaluation(evaluation, assignment.swaps) then bestEvaluation = evaluation bestSwapCount = assignment.swaps - bestEvaluation.theoreticalResistanceSwap = swaps - bestEvaluation.theoreticalResistanceSwapItemString = variant:BuildRaw() - bestEvaluation.theoreticalResistanceSwapLineIndexes = swappedLineIndexes + bestEvaluation.estimatedResistanceSwaps = swaps + bestEvaluation.estimatedResistanceSwapItemString = variant:BuildRaw() + bestEvaluation.estimatedResistanceSwapLineIndexes = swappedLineIndexes end end end @@ -1227,18 +1227,14 @@ function TradeQueryClass:ResetResultRow(rowIdx) self.itemIndexTbl[rowIdx] = nil self.sortedResultTbl[rowIdx] = nil self.resultTbl[rowIdx] = nil - self.unfilteredResultTbl[rowIdx] = nil self.onlyWeightedBaseOutput[rowIdx] = nil - self.resistanceBaseOutput[rowIdx] = nil + self.resistanceStateCache[rowIdx] = nil self.lastComparedWeightList[rowIdx] = nil self.totalPrice[rowIdx] = nil self:UpdateDropdownList(rowIdx) self.controls.fullPrice.label = "^7Total Price: " .. self:GetTotalPriceString() end function TradeQueryClass:UpdateControlsWithItems(row_idx, yieldFunc) - if self.unfilteredResultTbl[row_idx] then - self.resultTbl[row_idx] = self.unfilteredResultTbl[row_idx] - end local sortMode = self.itemSortSelectionList[self.pbItemSortSelectionIndex] local sortedItems, errMsg = self:SortFetchResults(row_idx, sortMode, yieldFunc) if errMsg == "MissingConversionRates" then @@ -1442,11 +1438,11 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro controls["uri"..context.row_idx]:SetText(url, true) return end - local fetchContext = self:StartResultFetch(context.row_idx) + local fetchToken = self:StartResultFetch(context.row_idx) self.lastQueries[row_idx] = query self:SearchGeneratedQuery(queryOptions, query, function(items, errMsg) - if not self:FinishResultFetch(context.row_idx, fetchContext) then + if not self:FinishResultFetch(context.row_idx, fetchToken) then return end if errMsg then @@ -1475,17 +1471,16 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro item.enchantModLines = {} end itemsSafe[i].item_string = item:BuildRaw() - itemsSafe[i].resistanceSwapEnabled = queryOptions and queryOptions.groupResists == true - itemsSafe[i].resistanceCapsRequired = queryOptions and queryOptions.includeResistCaps == true + itemsSafe[i].resistanceSwapEnabled = queryOptions and queryOptions.includeResistSwaps == true + itemsSafe[i].prioritiseResistanceCaps = queryOptions and queryOptions.includeResistCaps == true end - self.unfilteredResultTbl[context.row_idx] = queryOptions and queryOptions.includeResistCaps and itemsSafe or nil self.resultTbl[context.row_idx] = itemsSafe self:StartResultEvaluation(context.row_idx) end, { callbackQueryId = function(queryId) - if not self:IsResultFetchCurrent(context.row_idx, fetchContext) then + if not self:IsResultFetchCurrent(context.row_idx, fetchToken) then return end local url = self.tradeQueryRequests:buildUrl(self.hostName .. "trade/search", self.pbRealm, self.pbLeague, queryId) @@ -1543,13 +1538,13 @@ 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() - local fetchContext = self:StartResultFetch(row_idx) + local fetchToken = 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 + if not self:FinishResultFetch(row_idx, fetchToken) then return end if errMsg then @@ -1559,7 +1554,6 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite self.lastQueries[row_idx] = query local selectedSlot = getSelectedSlot() local itemsSafe = self:FilterToSafeItems(items, selectedSlot and selectedSlot.slotName) - self.unfilteredResultTbl[row_idx] = nil self.resultTbl[row_idx] = itemsSafe self:StartResultEvaluation(row_idx) end @@ -1568,7 +1562,7 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite controls["priceButton"..row_idx].enabled = function() local isAuthorized = main.api.authToken ~= nil local validURL = controls["uri"..row_idx].validURL - local isSearching = self.resultFetchContexts[row_idx] ~= nil + local isSearching = self.resultFetchTokens[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 @@ -1612,7 +1606,7 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end local function addResistanceSwapToTooltipIfApplicable(tooltip, result) local evaluation = result.evaluation and result.evaluation[1] - local swaps = evaluation and evaluation.theoreticalResistanceSwap + local swaps = evaluation and evaluation.estimatedResistanceSwaps if not swaps or #swaps == 0 then return end @@ -1622,22 +1616,22 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end local label = #swaps == 1 and "Estimated swap: " or "Estimated swaps: " local rollNote = #swaps == 1 and " (roll may change)" or " (rolls may change)" - local compareHint = evaluation.theoreticalResistanceSwapItemString and colorCodes.TIP .. " [Ctrl: compare]" or "" + local compareHint = evaluation.estimatedResistanceSwapItemString and colorCodes.TIP .. " [Ctrl: compare]" or "" tooltip:AddSeparator(10) tooltip:AddLine(16, "^7" .. label .. table.concat(descriptions, ", ") .. "^8" .. rollNote .. compareHint) return evaluation end local function addResistanceSwapPreviewIfApplicable(tooltip, evaluation, tooltipSlot) - if not evaluation or not evaluation.theoreticalResistanceSwapItemString or not self:IsResistanceSwapPreviewActive() then + if not evaluation or not evaluation.estimatedResistanceSwapItemString or not self:IsResistanceSwapPreviewActive() then return end - local previewItem = new("Item"):Item(evaluation.theoreticalResistanceSwapItemString) + local previewItem = new("Item"):Item(evaluation.estimatedResistanceSwapItemString) local previewTooltip = tooltip.resistanceSwapPreviewTooltip or new("Tooltip"):Tooltip() tooltip.resistanceSwapPreviewTooltip = previewTooltip previewTooltip:Clear() self.itemsTab:AddItemTooltip(previewTooltip, previewItem, tooltipSlot) local swappedModLines = {} - for _, lineIndex in ipairs(evaluation.theoreticalResistanceSwapLineIndexes or {}) do + for _, lineIndex in ipairs(evaluation.estimatedResistanceSwapLineIndexes or {}) do local modLine = previewItem.explicitModLines[lineIndex] if modLine then swappedModLines[modLine] = true diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index a54d8a078bd..d38e635cc2b 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -750,7 +750,7 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) -- Calculate base output with a blank item local calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() local baseItemOutput = slot and calcFunc({ repSlotName = slot.slotName, repItem = testItem }) or baseOutput - local resistCapShortfall = tradeResistanceGrouping.getResistanceCapShortfall( + local resistanceCapShortfallByType = tradeResistanceGrouping.getResistanceCapShortfallByType( slot and not slot.slotName:find("Flask") and baseItemOutput or {}) -- make weights more human readable local compStatValue = TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, baseItemOutput, options.statWeights) * 1000 @@ -770,7 +770,7 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) options = options, slot = slot, requiredMods = options.requiredMods, - resistCapShortfall = resistCapShortfall, + resistanceCapShortfallByType = resistanceCapShortfallByType, } -- OnFrame will pick this up and begin the work @@ -891,7 +891,7 @@ function TradeQueryGeneratorClass:FinishQuery() if self.calcContext.options.includeAllWEMods then self:addMoreWEMods() end - self.modWeights = tradeResistanceGrouping.groupResistanceWeights(self.modWeights, self.calcContext.options.groupResists, self.calcContext.options.includeResistCaps) + self.modWeights = tradeResistanceGrouping.applyResistanceWeightOptions(self.modWeights, self.calcContext.options.includeResistSwaps, self.calcContext.options.includeResistCaps) -- Sort by mean Stat diff rather than weight to more accurately prioritize stats that can contribute more table.sort(self.modWeights, function(a, b) @@ -1045,20 +1045,20 @@ function TradeQueryGeneratorClass:FinishQuery() filters = filters + 1 end if options.includeResistCaps then - local shortfall = self.calcContext.resistCapShortfall or {} + local shortfallByType = self.calcContext.resistanceCapShortfallByType or {} local function addResistanceMinimum(id, minimum) if minimum and minimum > 0 then t_insert(andFilters.filters, { id = id, value = { min = minimum } }) filters = filters + 1 end end - if options.groupResists then - local elementalMinimum = (shortfall.Fire or 0) + (shortfall.Cold or 0) + (shortfall.Lightning or 0) + if options.includeResistSwaps then + local elementalMinimum = (shortfallByType.Fire or 0) + (shortfallByType.Cold or 0) + (shortfallByType.Lightning or 0) addResistanceMinimum("pseudo.pseudo_total_elemental_resistance", elementalMinimum) - addResistanceMinimum(resistancePseudoIds.Chaos, shortfall.Chaos) + addResistanceMinimum(resistancePseudoIds.Chaos, shortfallByType.Chaos) else for _, resistanceType in ipairs(resistanceTypes) do - addResistanceMinimum(resistancePseudoIds[resistanceType], shortfall[resistanceType]) + addResistanceMinimum(resistancePseudoIds[resistanceType], shortfallByType[resistanceType]) end end end @@ -1157,7 +1157,7 @@ function TradeQueryGeneratorClass:FinishQuery() local queryJson = dkjson.encode(queryTable) self.requesterCallback(self.requesterContext, queryJson, errMsg, { - groupResists = options.groupResists == true, + includeResistSwaps = options.includeResistSwaps == true, includeResistCaps = options.includeResistCaps == true, weightAdjustedSearch = hasWeightedFilters and not options.includeResistCaps, }) @@ -1335,10 +1335,10 @@ Remove: %s will be removed from the search results.]], term, term, term) updateLastAnchor(controls.maxLevel) if not context.slotTbl.unique then - controls.groupResists = new("CheckBoxControl"):CheckBoxControl({ "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 18 }, "Resistance swaps:", function(state) end) - controls.groupResists.state = self.lastGroupResists == true - controls.groupResists.tooltipText = "Searches Fire, Cold, and Lightning Resistance as one total.\nResults are sorted using the best estimated swap; rolls may change." - updateLastAnchor(controls.groupResists) + controls.includeResistSwaps = new("CheckBoxControl"):CheckBoxControl({ "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 18 }, "Resistance swaps:", function(state) end) + controls.includeResistSwaps.state = self.lastIncludeResistSwaps == true + controls.includeResistSwaps.tooltipText = "Searches Fire, Cold, and Lightning Resistance as one total.\nResults are sorted using the best estimated swap; rolls may change." + updateLastAnchor(controls.includeResistSwaps) controls.includeResistCaps = new("CheckBoxControl"):CheckBoxControl({ "TOPLEFT", lastItemAnchor, "BOTTOMLEFT" }, { 0, 5, 18 }, "Resistance caps:", function(state) end) controls.includeResistCaps.state = self.lastIncludeResistCaps == true @@ -1451,9 +1451,9 @@ Remove: %s will be removed from the search results.]], term, term, term) if #selectedMods > 0 then options.requiredMods = copyTable(selectedMods) end - if controls.groupResists then - self.lastGroupResists = controls.groupResists.state - options.groupResists = controls.groupResists.state + if controls.includeResistSwaps then + self.lastIncludeResistSwaps = controls.includeResistSwaps.state + options.includeResistSwaps = controls.includeResistSwaps.state end if controls.includeResistCaps then self.lastIncludeResistCaps = controls.includeResistCaps.state diff --git a/src/Classes/TradeQueryRequests.lua b/src/Classes/TradeQueryRequests.lua index 850d31e81f9..133b0c5f1bc 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -295,6 +295,8 @@ function TradeQueryRequestsClass:FetchResultBlock(url, callback) for _, trade_entry in pairs(response.result) do local item = trade_entry.item local t_insert = table.insert + -- The API affix and hash metadata is not preserved by PoB's raw item + -- format, so extract swap descriptors before serialising the item. local resistanceSwapDescriptors = tradeResistanceSwap.extractDescriptors(item) local rawLines = {} diff --git a/src/Classes/TradeResistanceGrouping.lua b/src/Classes/TradeResistanceGrouping.lua index 6ec0a3c77ff..11d7eab67a4 100644 --- a/src/Classes/TradeResistanceGrouping.lua +++ b/src/Classes/TradeResistanceGrouping.lua @@ -13,7 +13,7 @@ local elementSet = { Lightning = true, } -function M.getResistanceCapShortfall(output) +function M.getResistanceCapShortfallByType(output) local shortfall = {} for _, resistanceType in ipairs(resistanceTypes) do shortfall[resistanceType] = math.max(0, output["Missing" .. resistanceType .. "Resist"] or 0) @@ -73,8 +73,11 @@ local function makePseudoWeight(id, aggregate) } end -function M.groupResistanceWeights(modWeights, groupResists, includeResistCaps) - if not groupResists and not includeResistCaps then +-- Swap searches fold interchangeable elemental weights into one pseudo filter. +-- Cap searches take precedence and remove resistance weights because their +-- minimum filters are emitted separately from the current cap shortfall. +function M.applyResistanceWeightOptions(modWeights, includeResistSwaps, includeResistCaps) + if not includeResistSwaps and not includeResistCaps then return modWeights end diff --git a/src/Classes/TradeResistanceSwap.lua b/src/Classes/TradeResistanceSwap.lua index f5951377bad..38e41055e35 100644 --- a/src/Classes/TradeResistanceSwap.lua +++ b/src/Classes/TradeResistanceSwap.lua @@ -1,7 +1,7 @@ -- Path of Building -- -- Module: Trade Resistance Swap --- Extracts safe resistance-swap metadata and builds theoretical item variants. +-- Extracts safe resistance-swap metadata and builds estimated item variants. -- local M = {} @@ -32,14 +32,14 @@ local function getHashGroups(item) return groupsByDomain end -local function getUniqueMod(modLine) +local function getSingleModMetadata(modLine) local metadata = type(modLine.mods) == "table" and modLine.mods return metadata and #metadata == 1 and metadata[1] end local function getAffixFingerprint(modLine) local domain = modLine.domain - local mod = getUniqueMod(modLine) + local mod = getSingleModMetadata(modLine) if (domain ~= "explicit" and domain ~= "crafted") or not mod or type(mod.name) ~= "string" or mod.name == "" or type(mod.tier) ~= "string" or mod.tier == "" @@ -51,7 +51,7 @@ end local function getLineGroups(modLine, groupsByDomain) local domain = modLine.domain - local metadata = getUniqueMod(modLine) + local metadata = getSingleModMetadata(modLine) local magnitude = metadata and type(metadata.magnitudes) == "table" and metadata.magnitudes[1] local rawHash = modLine.hash or metadata and metadata.hash or magnitude and magnitude.hash local hash = type(rawHash) == "string" and rawHash:gsub("^stat%.", "") @@ -90,6 +90,8 @@ function M.extractDescriptors(item) metadataComplete = false end end + -- A partial descriptor set could make two lines from the same affix appear + -- independently swappable, so ambiguous metadata disables every swap. if not metadataComplete then return {} end @@ -104,7 +106,7 @@ function M.extractDescriptors(item) if type(modLine.description) == "string" then value, element = modLine.description:match("^%+(%d+%.?%d*)%% to (%a+) Resistance$") end - local mod = getUniqueMod(modLine) + local mod = getSingleModMetadata(modLine) local magnitudes = mod and mod.magnitudes local magnitude = type(magnitudes) == "table" and #magnitudes == 1 and magnitudes[1] local groups = getLineGroups(modLine, groupsByDomain) @@ -123,8 +125,6 @@ function M.extractDescriptors(item) lineIndex = lineIndex, element = element, domain = domain, - tier = mod.tier, - range = { min = tonumber(magnitude.min), max = tonumber(magnitude.max) }, }) seenElements[element] = true end @@ -151,6 +151,8 @@ function M.getAssignments(descriptors) local assignments = {} local assignment = {} local used = {} + -- Each source line must map to a distinct target element; assigning two + -- affixes to the same element cannot represent a valid swap assignment. local function visit(index, swaps) if index > #descriptors then local targets = {} @@ -183,7 +185,7 @@ local function readResistanceLine(modLine) end end -function M.validateItem(item, descriptors) +function M.itemMatchesSwapDescriptors(item, descriptors) if not item or item.corrupted or item.mirrored or item.duplicated then return false end @@ -200,7 +202,7 @@ end function M.buildVariant(itemString, descriptors, assignment) local item = new("Item"):Item(itemString) - if not M.validateItem(item, descriptors) then + if not M.itemMatchesSwapDescriptors(item, descriptors) then return end local swaps = {} @@ -214,7 +216,7 @@ function M.buildVariant(itemString, descriptors, assignment) end if target ~= source then modLine.line = modLine.line:gsub(" " .. source .. " Resistance$", " " .. target .. " Resistance") - table.insert(swaps, { from = source, to = target, value = tonumber(value) }) + table.insert(swaps, { from = source, to = target }) table.insert(swappedLineIndexes, descriptor.lineIndex) end end