From eac48da4f0c9e649cf108f815e2c5e3777fdc5e8 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Wed, 5 Aug 2026 00:36:25 +0200 Subject: [PATCH 1/2] 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 37670d27275..7fa72ecfa81 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", { 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", { itemsTab = { items = { } } }) diff --git a/spec/System/TestTradeQueryRequests_spec.lua b/spec/System/TestTradeQueryRequests_spec.lua index f8e53ea5e7b..53a88458aab 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 68eaf5d32c7..032d988e8dd 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 aa7c29a4636..615bb6890ff 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 + local TradeQueryClass = newClass("TradeQuery", function(self, itemsTab) self.itemsTab = itemsTab self.itemsTab.leagueDropList = { } @@ -26,10 +48,12 @@ local TradeQueryClass = newClass("TradeQuery", function(self, 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 @@ -803,12 +827,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 @@ -836,10 +867,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", 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 @@ -865,11 +935,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 @@ -1000,6 +1077,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 @@ -1017,7 +1127,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", 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", { "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 @@ -1032,7 +1142,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) @@ -1061,8 +1171,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" @@ -1078,7 +1191,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.]] @@ -1187,6 +1300,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 @@ -1202,6 +1329,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 @@ -1257,19 +1385,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 f4a824c1f61..22f403f50f7 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 @@ -569,7 +578,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 @@ -736,6 +746,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 @@ -754,6 +766,7 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) options = options, slot = slot, requiredMods = options.requiredMods, + resistCapShortfall = resistCapShortfall, } -- OnFrame will pick this up and begin the work @@ -874,6 +887,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) @@ -887,7 +901,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 = { @@ -899,8 +913,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 { @@ -1012,7 +1026,6 @@ function TradeQueryGeneratorClass:FinishQuery() ::weightContinue:: end - for k, v in pairs(self.calcContext.special.queryExtra or {}) do queryTable.query[k] = v end @@ -1027,17 +1040,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 } }) @@ -1108,14 +1139,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() @@ -1290,6 +1331,18 @@ Remove: %s will be removed from the search results.]], term, term, term) controls.maxLevelLabel = new("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", {"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 70, 18}, nil, nil, "%D") @@ -1395,6 +1448,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 3bda7452a4a..80eb2fd8bf5 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 @@ -292,6 +293,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) @@ -342,6 +344,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)) @@ -365,7 +370,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, @@ -374,7 +379,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 3206e113b6854ed10c078a69b0d69b08eb16611b Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 7 Aug 2026 21:14:47 +0200 Subject: [PATCH 2/2] 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 032d988e8dd..a486fe6bfe1 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 615bb6890ff..3a541e3c488 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -801,6 +801,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 = {} @@ -886,11 +890,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) @@ -900,6 +905,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 @@ -1308,11 +1315,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] @@ -1329,7 +1364,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 22f403f50f7..bef31508280 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -1334,12 +1334,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