diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index e11ea701b9..fa8e31f636 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 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.getResistanceCapShortfallByType({ + MissingFireResist = 12, + MissingColdResist = -3, + MissingLightningResist = 34, + MissingChaosResist = 56, + })) + end) + + it("annotates weights through the real GenerateModWeights method", function() + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) + queryGen.modWeights = {} + queryGen.alreadyWeightedMods = {} + queryGen.calcContext = { + itemCategory = "Ring", + testItem = new("Item"):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"):TradeQueryGenerator({ itemsTab = {} }) + queryGen.tradeTypeIndex = 4 + queryGen.modWeights = weights + queryGen.calcContext = { + itemCategoryQueryStr = "accessory.ring", + special = {}, + testItem = new("Item"):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 } }, + includeResistSwaps = options.includeResistSwaps, + includeResistCaps = options.includeResistCaps, + }, + requiredMods = options.requiredMods or {}, + resistanceCapShortfallByType = options.resistanceCapShortfallByType, + } + 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({ 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 }, + }) + 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({ includeResistSwaps = 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({ includeResistSwaps = 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({ includeResistSwaps = 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 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({ includeResistSwaps = true, includeResistCaps = false, weightAdjustedSearch = true }, queryOptions) + end) + + it("normalises multi-element resistance weights before pseudo grouping", function() + local query = finishQuery({ includeResistSwaps = 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, + 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), + 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({ + includeResistSwaps = true, + includeResistCaps = true, + 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 }, + }) + 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, + resistanceCapShortfallByType = { 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, + resistanceCapShortfallByType = { 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, + resistanceCapShortfallByType = { 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 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 }, + }) + 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 a872f1ecf9..8ea1c6c0a5 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,142 @@ 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", + }, + }, 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"):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"):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 9a83a331c4..b80ab615d4 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -6,6 +6,120 @@ 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"):TradeQuery({ itemsTab = {} }) + tradeQuery.controls.priceButton1 = { label = "Price Item" } + tradeQuery.controls.pbNotice = { label = "" } + tradeQuery.resultTbl[1] = { { }, { } } + local events = { } + tradeQuery.UpdateControlsWithItems = function(_, _, yieldFunc) + table.insert(events, "first") + yieldFunc(1, 2) + table.insert(events, "second") + yieldFunc(2, 2) + table.insert(events, "done") + end + + tradeQuery:StartResultEvaluation(1) + + assert.are.same({ }, events) + assert.are.equal("Eval 0/2...", tradeQuery.controls.priceButton1.label) + + tradeQuery:ProcessResultEvaluations() + assert.are.same({ "first" }, events) + assert.are.equal("Eval 1/2...", tradeQuery.controls.priceButton1.label) + + tradeQuery:ProcessResultEvaluations() + assert.are.same({ "first", "second" }, events) + assert.are.equal("Eval 2/2...", tradeQuery.controls.priceButton1.label) + + tradeQuery:ProcessResultEvaluations() + assert.are.same({ "first", "second", "done" }, events) + assert.are.equal("Price Item", tradeQuery.controls.priceButton1.label) + assert.is_nil(tradeQuery.resultEvaluationContexts[1]) + end) + + it("clears the prior selection before scheduling a new evaluation", function() + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + local dropdownList + tradeQuery.controls.priceButton1 = { label = "Price Item" } + tradeQuery.controls.resultDropdown1 = { + SetList = function(_, list) + dropdownList = list + end, + } + tradeQuery.controls.fullPrice = { label = "" } + tradeQuery.resultTbl[1] = { { } } + tradeQuery.sortedResultTbl[1] = { { index = 1 } } + tradeQuery.itemIndexTbl[1] = 1 + tradeQuery.totalPrice[1] = { amount = 1, currency = "chaos" } + tradeQuery.UpdateControlsWithItems = function() end + + tradeQuery:StartResultEvaluation(1) + + assert.is_nil(tradeQuery.sortedResultTbl[1]) + assert.is_nil(tradeQuery.itemIndexTbl[1]) + assert.is_nil(tradeQuery.totalPrice[1]) + assert.are.same({ }, dropdownList) + assert.are.equal("^7Total Price: ", tradeQuery.controls.fullPrice.label) + end) + + it("does not replace an active fetch with evaluation of old results", function() + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tradeQuery.controls.priceButton1 = { label = "Price Item" } + tradeQuery.resultTbl[1] = { { } } + local evaluated = false + tradeQuery.UpdateControlsWithItems = function() + evaluated = true + end + + local fetchToken = tradeQuery:StartResultFetch(1) + tradeQuery:StartResultEvaluation(1) + + 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) + end) + + it("rejects a response from a superseded fetch", function() + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tradeQuery.controls.priceButton1 = { label = "Price Item" } + + local firstFetch = tradeQuery:StartResultFetch(1) + local secondFetch = tradeQuery:StartResultFetch(1) + + assert.is_false(tradeQuery:FinishResultFetch(1, firstFetch)) + assert.are.equal("Searching...", tradeQuery.controls.priceButton1.label) + assert.is_true(tradeQuery:FinishResultFetch(1, secondFetch)) + assert.are.equal("Price Item", tradeQuery.controls.priceButton1.label) + end) + + it("publishes only the replacement of a suspended evaluation", function() + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tradeQuery.controls.priceButton1 = { label = "Price Item" } + tradeQuery.controls.pbNotice = { label = "" } + tradeQuery.resultTbl[1] = { { } } + local run = 0 + local published + tradeQuery.UpdateControlsWithItems = function(_, _, yieldFunc) + run = run + 1 + local currentRun = run + yieldFunc(1, 1) + published = currentRun + end + + tradeQuery:StartResultEvaluation(1) + tradeQuery:ProcessResultEvaluations() + tradeQuery:StartResultEvaluation(1) + tradeQuery:ProcessResultEvaluations() + tradeQuery:ProcessResultEvaluations() + + assert.are.equal(2, published) + assert.is_nil(tradeQuery.resultEvaluationContexts[1]) + end) + end) describe("result dropdown tooltipFunc", function() -- Builds a TradeQuery with the strict minimum needed for -- PriceItemRowDisplay to construct row 1 without exploding. Only the @@ -60,6 +174,139 @@ describe("TradeQuery", function() end) assert.are.equal(0, #tooltip.lines) end) + + 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] = { + item_string = itemString, + amount = 1, + currency = "chaos", + evaluation = { { + output = {}, + weight = 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 } } }, + }) + tq.itemsTab.AddItemTooltip = function() end + local dropdown = buildRow1Dropdown(tq) + local tooltip = new("Tooltip"):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 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, + estimatedResistanceSwaps = { + { from = "Fire", to = "Cold" }, + { from = "Cold", to = "Lightning" }, + }, + 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 } } }, + }) + 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"):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) + describe("result action controls", function() + it("ignore a stale selection while asynchronous evaluation is pending", function() + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tradeQuery.itemsTab.activeItemSet = {} + tradeQuery.itemsTab.slots = {} + tradeQuery.slotTables[1] = { slotName = "Ring 1" } + tradeQuery.resultTbl[1] = { { + item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring", + amount = 1, + currency = "chaos", + } } + tradeQuery.sortedResultTbl[1] = { { index = 1 } } + tradeQuery:PriceItemRowDisplay(1, nil, 0, 20) + tradeQuery.itemIndexTbl[1] = 2 + local tooltip = new("Tooltip"):Tooltip() + + assert.has_no.errors(function() + tradeQuery.controls.importButton1.tooltipFunc(tooltip) + end) + assert.is_false(tradeQuery.controls.importButton1.enabled()) + assert.has_no.errors(function() + tradeQuery.controls.whisperButton1.tooltipFunc(tooltip) + end) + end) + + it("replaces fetched 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.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.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() @@ -114,4 +361,463 @@ 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", + } + end + + local function newEvaluationQuery(lines, descriptors, enabled, prioritiseCaps) + local tq = new("TradeQuery"):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, + prioritiseResistanceCaps = prioritiseCaps, + } } + 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("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.ResistanceSwapMayAffectOutput = 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].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.ResistanceSwapMayAffectOutput = 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].estimatedResistanceSwaps[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.ResistanceSwapMayAffectOutput = 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:ResistanceSwapMayAffectOutput()) + assert.is_true(tq:ResistanceSwapMayAffectOutput({ modList = { { + name = "FirePenIncreasedByUncappedFireRes", + type = "FLAG", + } } })) + assert.is_true(tq:ResistanceSwapMayAffectOutput({ modList = { { + name = "DamageIncreasedByOvercappedColdRes", + type = "FLAG", + } } })) + + tq.statSortSelectionList = { { stat = "FireResistTotal", weightMult = 1 } } + assert.is_true(tq:ResistanceSwapMayAffectOutput()) + + 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:ResistanceSwapMayAffectOutput()) + + tq.itemsTab.build.calcsTab.mainEnv.player.modDB.mods = { + FirePenIncreasedByUncappedFireRes = { { + name = "FirePenIncreasedByUncappedFireRes", + type = "FLAG", + } }, + } + assert.is_true(tq:ResistanceSwapMayAffectOutput()) + 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("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" }, + { 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].estimatedResistanceSwaps + + assert.are.equal(2, #swaps) + 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 evaluated 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].estimatedResistanceSwaps) + end) + + 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), + 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 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) + 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].estimatedResistanceSwaps) + end) + + 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(1, #evaluation) + assert.are.equal(40, evaluation[1].totalResistanceCapShortfall) + end) + + 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( + { "+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 })) + local invalidEvaluation = invalid:GetResultEvaluation(1, 1, calc, { Life = 100 }) + assert.are.equal(1, #invalidEvaluation) + assert.are.equal(1, invalidEvaluation[1].totalResistanceCapShortfall) + end) + + 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(1, #evaluation) + 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", prioritiseResistanceCaps = true }, + { id = "capped", prioritiseResistanceCaps = true }, + { id = "unrestricted" }, + } + tq.sortModes = { StatValue = "statValue" } + tq.itemsTab.build = { calcsTab = { GetMiscCalculator = function() + return function() return { } end, { } + end } } + tq.GetResultEvaluation = function(_, _, resultIndex) + return { { weight = 4 - resultIndex, totalResistanceCapShortfall = resultIndex == 1 and 10 or 0 } } + end + + local sorted = tq:SortFetchResults(1, tq.sortModes.StatValue) + + 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) + + 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 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, + } } + + local first = tq:GetResultEvaluation(1, 1) + assert.are.equal(1, #first) + 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].totalResistanceCapShortfall) + 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) + + 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 7ea2fcedf0..7f98eec757 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,121 @@ 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 getTotalResistanceCapShortfall(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 getResistanceStateSnapshot(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 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 assignmentTargetsMissingResistance(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] then + return true + end + end + return false +end + +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) + or value:find("ColdRes", 1, true) + or value:find("LightningRes", 1, true)) +end + +local function isElementalResistanceStat(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 modUsesElementalResistanceState(mod) + for _, tag in ipairs(mod or { }) do + if type(tag) == "table" then + for _, value in pairs(tag) do + if isElementalResistanceStat(value) then + return true + end + end + end + end + return false +end + +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 isElementalResistanceStateFlag(name) then + return true + end + for _, mod in ipairs(modList) do + if modUsesElementalResistanceState(mod) then + return true + end + end + end + else + for _, mod in ipairs(store) do + if isElementalResistanceStateFlag(mod.name) or modUsesElementalResistanceState(mod) then + return true + end + end + end + return modStoreUsesElementalResistanceState(store.parent, visited) +end + ---@class TradeQuery local TradeQueryClass = newClass("TradeQuery") @@ -33,6 +149,7 @@ function TradeQueryClass:TradeQuery(itemsTab) self.itemIndexTbl = { } -- tooltip acceleration tables self.onlyWeightedBaseOutput = { } + self.resistanceStateCache = { } self.lastComparedWeightList = { } -- default set of trade item sort selection @@ -58,6 +175,14 @@ function TradeQueryClass:TradeQuery(itemsTab) self.backoffFinish = nil -- last query for each row self.lastQueries = {} + -- Result evaluation is resumed one calculation at a time so expensive + -- build comparisons never monopolise the UI thread after a fetch. + self.resultEvaluationContexts = {} + self.resultEvaluationQueue = {} + self.resultEvaluationQueued = {} + -- Identity tokens keep network fetches separate from result evaluation and + -- prevent an older response from replacing a newer search. + self.resultFetchTokens = {} self.tradeQueryRequests = new("TradeQueryRequests"):TradeQueryRequests() if not main.api then @@ -443,7 +568,9 @@ on trade site to work on other leagues and realms)]] self.controls.itemSortSelection = new("DropDownControl"):DropDownControl({"TOPRIGHT", self.controls.StatWeightMultipliersButton, "TOPLEFT"}, {-8, 0, 170, row_height}, self.itemSortSelectionList, function(index, value) self.pbItemSortSelectionIndex = index for row_idx, _ in pairs(self.resultTbl) do - self:UpdateControlsWithItems(row_idx) + if not self.resultFetchTokens[row_idx] then + self:StartResultEvaluation(row_idx) + end end end) self.controls.itemSortSelection.tooltipText = @@ -654,6 +781,7 @@ Highest Weight - Displays the order retrieved from trade]] end main.onFrameFuncs["TradeQueryRequests"] = function() self.tradeQueryRequests:ProcessQueue(onRateLimit) + self:ProcessResultEvaluations() if self.countDown then coroutine.resume(self.countDown) if coroutine.status(self.countDown) == "dead" then @@ -747,7 +875,9 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) self.statSortSelectionList = statSortSelectionList end for row_idx in pairs(self.resultTbl) do - self:UpdateControlsWithItems(row_idx) + if not self.resultFetchTokens[row_idx] then + self:StartResultEvaluation(row_idx) + end end end) controls.cancel = new("ButtonControl"):ButtonControl({ "BOTTOM", nil, "BOTTOM" }, { 0, -10, 80, 20 }, "Cancel", function() @@ -781,6 +911,135 @@ function TradeQueryClass:SetNotice(notice_control, msg) notice_control.label = msg end +function TradeQueryClass:SetResultEvaluationProgress(rowIdx, current, total) + local button = self.controls["priceButton" .. rowIdx] + if button then + button.label = s_format("Eval %d/%d...", current or 0, total or 0) + end +end + +function TradeQueryClass:CancelResultEvaluation(rowIdx) + self.resultEvaluationContexts[rowIdx] = nil + local button = self.controls["priceButton" .. rowIdx] + if button then + button.label = "Price Item" + end +end + +function TradeQueryClass:StartResultFetch(rowIdx) + self:CancelResultEvaluation(rowIdx) + local fetchToken = { } + self.resultFetchTokens[rowIdx] = fetchToken + local button = self.controls["priceButton" .. rowIdx] + if button then + button.label = "Searching..." + end + return fetchToken +end + +function TradeQueryClass:IsResultFetchCurrent(rowIdx, fetchToken) + return self.resultFetchTokens[rowIdx] == fetchToken +end + +function TradeQueryClass:FinishResultFetch(rowIdx, fetchToken) + if not self:IsResultFetchCurrent(rowIdx, fetchToken) then + return false + end + self.resultFetchTokens[rowIdx] = nil + local button = self.controls["priceButton" .. rowIdx] + if button then + button.label = "Price Item" + end + return true +end + +function TradeQueryClass:CancelResultFetch(rowIdx) + self.resultFetchTokens[rowIdx] = nil + self:CancelResultEvaluation(rowIdx) +end + +function TradeQueryClass:StartResultEvaluation(rowIdx) + if self.resultFetchTokens[rowIdx] then + return + end + local results = self.resultTbl[rowIdx] or { } + self.itemIndexTbl[rowIdx] = nil + self.sortedResultTbl[rowIdx] = nil + self.totalPrice[rowIdx] = nil + local dropdown = self.controls["resultDropdown" .. rowIdx] + if dropdown then + dropdown.selIndex = 1 + dropdown:SetList({ }) + end + if self.controls.fullPrice then + self.controls.fullPrice.label = "^7Total Price: " .. self:GetTotalPriceString() + end + local context = { + total = #results, + } + context.co = coroutine.create(function() + self:UpdateControlsWithItems(rowIdx, function(current, total) + if self.resultEvaluationContexts[rowIdx] ~= context then + return + end + self:SetResultEvaluationProgress(rowIdx, current, total) + coroutine.yield() + end) + end) + self.resultEvaluationContexts[rowIdx] = context + self:SetResultEvaluationProgress(rowIdx, 0, context.total) + if not self.resultEvaluationQueued[rowIdx] then + t_insert(self.resultEvaluationQueue, rowIdx) + self.resultEvaluationQueued[rowIdx] = true + end +end + +function TradeQueryClass:ProcessResultEvaluations() + local rowIdx = t_remove(self.resultEvaluationQueue, 1) + if not rowIdx then + return + end + self.resultEvaluationQueued[rowIdx] = nil + local context = self.resultEvaluationContexts[rowIdx] + if not context then + return + end + + local ok, errMsg = coroutine.resume(context.co) + if not ok then + if self.resultEvaluationContexts[rowIdx] == context then + self.resultEvaluationContexts[rowIdx] = nil + local button = self.controls["priceButton" .. rowIdx] + if button then + button.label = "Price Item" + end + if self.controls.pbNotice then + self:SetNotice(self.controls.pbNotice, "Error while evaluating trade results: " .. tostring(errMsg)) + end + end + ConPrintf("Trade result evaluation error: %s", errMsg) + return + end + + if self.resultEvaluationContexts[rowIdx] ~= context then + return + end + if coroutine.status(context.co) == "dead" then + self.resultEvaluationContexts[rowIdx] = nil + local button = self.controls["priceButton" .. rowIdx] + if button then + button.label = "Price Item" + end + else + t_insert(self.resultEvaluationQueue, rowIdx) + self.resultEvaluationQueued[rowIdx] = true + end +end + +function TradeQueryClass:IsResistanceSwapPreviewActive() + return IsKeyDown("CTRL") +end + -- Method to reduce the full output to only the values that were 'weighted' function TradeQueryClass:ReduceOutput(output) local smallOutput = {} @@ -795,25 +1054,69 @@ 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] = { } +-- 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 isElementalResistanceStat(stat.stat) then + return true end - if not self.lastComparedWeightList[row_idx] then - self.lastComparedWeightList[row_idx] = { } + end + local visited = { } + 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 modStoreUsesElementalResistanceState(modList, visited) then + return true end - -- If the interesting stats are the same (the build hasn't changed) and result has already been evaluated, then just return that - if result.evaluation and tableDeepEquals(onlyWeightedBaseOutput, self.onlyWeightedBaseOutput[row_idx][result_index]) and tableDeepEquals(self.statSortSelectionList, self.lastComparedWeightList[row_idx][result_index]) then - return result.evaluation + 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, the resistance state cannot be proven irrelevant. + return true + end + if modStoreUsesElementalResistanceState(player.modDB, visited) then + return true + end + for _, activeSkill in ipairs(player.activeSkillList or { }) do + if modStoreUsesElementalResistanceState(activeSkill.skillModList, visited) + or modStoreUsesElementalResistanceState(activeSkill.modList, visited) then + return true end - self.onlyWeightedBaseOutput[row_idx][result_index] = onlyWeightedBaseOutput - self.lastComparedWeightList[row_idx][result_index] = self.statSortSelectionList end + return false +end + +-- 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 + 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.resistanceStateCache[row_idx] then + self.resistanceStateCache[row_idx] = { } + end + 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.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.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 @@ -822,10 +1125,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 @@ -840,10 +1150,57 @@ 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.itemMatchesSwapDescriptors(item, descriptors) + and tradeResistanceSwap.getAssignments(descriptors) or {} + local bestEvaluation + local bestSwapCount + local function evaluateVariant(variant) + local fullOutput = calcFunc({ repSlotName = slotName, repItem = variant }) + if yieldFunc then + yieldFunc() + end + local output = self:ReduceOutput(fullOutput) + return { + output = output, + weight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, output, self.statSortSelectionList), + 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.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 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 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.estimatedResistanceSwaps = swaps + bestEvaluation.estimatedResistanceSwapItemString = variant:BuildRaw() + bestEvaluation.estimatedResistanceSwapLineIndexes = swappedLineIndexes + end + end + end + end + result.evaluation = { bestEvaluation } end return result.evaluation end @@ -866,19 +1223,23 @@ function TradeQueryClass:UpdateDropdownList(row_idx) self.controls["resultDropdown".. row_idx]:SetList(dropdownLabels) end function TradeQueryClass:ResetResultRow(rowIdx) + self:CancelResultFetch(rowIdx) self.itemIndexTbl[rowIdx] = nil self.sortedResultTbl[rowIdx] = nil self.resultTbl[rowIdx] = nil + self.onlyWeightedBaseOutput[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) +function TradeQueryClass:UpdateControlsWithItems(row_idx, yieldFunc) local sortMode = self.itemSortSelectionList[self.pbItemSortSelectionIndex] - local sortedItems, errMsg = self:SortFetchResults(row_idx, sortMode) + local sortedItems, errMsg = self:SortFetchResults(row_idx, sortMode, yieldFunc) if errMsg == "MissingConversionRates" then self:SetNotice(self.controls.pbNotice, "^4Currency rates unavailable. Falling back to Stat Value sort.") - sortedItems, errMsg = self:SortFetchResults(row_idx, self.sortModes.StatValue) + sortedItems, errMsg = self:SortFetchResults(row_idx, self.sortModes.StatValue, yieldFunc) elseif errMsg then self:SetNotice(self.controls.pbNotice, "Error: " .. errMsg) return @@ -915,18 +1276,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 @@ -944,15 +1325,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 @@ -970,20 +1352,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 @@ -1004,6 +1385,28 @@ function TradeQueryClass:FilterToSafeItems(itemEntries, slotName) end return itemsSafe 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 +1424,8 @@ 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: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) return @@ -1034,13 +1438,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 fetchToken = self:StartResultFetch(context.row_idx) self.lastQueries[row_idx] = query - self.tradeQueryRequests:SearchWithQueryWeightAdjusted(self.pbRealm, self.pbLeague, query, + self:SearchGeneratedQuery(queryOptions, query, function(items, errMsg) + if not self:FinishResultFetch(context.row_idx, fetchToken) 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, "") @@ -1065,14 +1471,18 @@ 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.includeResistSwaps == true + itemsSafe[i].prioritiseResistanceCaps = queryOptions and queryOptions.includeResistCaps == true end self.resultTbl[context.row_idx] = itemsSafe - self:UpdateControlsWithItems(context.row_idx) - context.controls["priceButton"..context.row_idx].label = "Price Item" + self:StartResultEvaluation(context.row_idx) end, { callbackQueryId = function(queryId) + if not self:IsResultFetchCurrent(context.row_idx, fetchToken) 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 @@ -1082,7 +1492,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.]] @@ -1128,12 +1538,15 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end controls["priceButton"..row_idx] = new("ButtonControl"):ButtonControl({ "TOPLEFT", controls["uri"..row_idx], "TOPRIGHT"}, {8, 0, 100, row_height}, "Price Item", function() - controls["priceButton"..row_idx].label = "Searching..." + local 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, fetchToken) then + return + end if errMsg then self:SetNotice(controls.pbNotice, "Error: " .. errMsg) else @@ -1142,18 +1555,18 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite local selectedSlot = getSelectedSlot() local itemsSafe = self:FilterToSafeItems(items, selectedSlot and selectedSlot.slotName) self.resultTbl[row_idx] = itemsSafe - self:UpdateControlsWithItems(row_idx) + self:StartResultEvaluation(row_idx) end - controls["priceButton"..row_idx].label = "Price Item" end) end) controls["priceButton"..row_idx].enabled = function() local isAuthorized = main.api.authToken ~= nil local validURL = controls["uri"..row_idx].validURL - local isSearching = controls["priceButton"..row_idx].label == "Searching..." + local isSearching = self.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 - return isAuthorized and validURL and not isSearching and hasRequiredJewelSlot + return isAuthorized and validURL and not isSearching and not isEvaluating and hasRequiredJewelSlot end controls["priceButton"..row_idx].tooltipFunc = function(tooltip) tooltip:Clear() @@ -1191,6 +1604,48 @@ 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.estimatedResistanceSwaps + if not swaps or #swaps == 0 then + return + end + local descriptions = {} + for _, swap in ipairs(swaps) do + 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.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.estimatedResistanceSwapItemString or not self:IsResistanceSwapPreviewActive() then + return + end + 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.estimatedResistanceSwapLineIndexes 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] if not sortedRow or not sortedRow[dropdown_index] then @@ -1206,11 +1661,22 @@ 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) + 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 + local function getSelectedResult() + local resultIndex = self.itemIndexTbl[row_idx] + local resultRow = self.resultTbl[row_idx] + return resultIndex and resultRow and resultRow[resultIndex] + end controls["importButton"..row_idx] = new("ButtonControl"):ButtonControl({ "TOPLEFT", controls["resultDropdown"..row_idx], "TOPRIGHT"}, {8, 0, 100, row_height}, "Import Item", function() - self.itemsTab:CreateDisplayItemFromRaw(self.resultTbl[row_idx][self.itemIndexTbl[row_idx]].item_string) + local selectedResult = getSelectedResult() + if not selectedResult or not selectedResult.item_string then + return + end + self.itemsTab:CreateDisplayItemFromRaw(selectedResult.item_string) local item = self.itemsTab.displayItem -- pass "true" to not auto equip it as we will have our own logic self.itemsTab:AddDisplayItem(true) @@ -1226,21 +1692,23 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end) controls["importButton"..row_idx].tooltipFunc = function(tooltip) tooltip:Clear() - local selected_result_index = self.itemIndexTbl[row_idx] - local item_string = self.resultTbl[row_idx][selected_result_index].item_string - if selected_result_index and item_string then - local item = new("Item"):Item(item_string) - local tooltipSlot = slotTbl.selectedJewelNodeId and self.itemsTab.sockets[slotTbl.selectedJewelNodeId] or activeSlot - self.itemsTab:AddItemTooltip(tooltip, item, tooltipSlot, true) - addMegalomaniacCompareToTooltipIfApplicable(tooltip, selected_result_index) + local selectedResult = getSelectedResult() + if not selectedResult or not selectedResult.item_string then + return end + local item = new("Item"):Item(selectedResult.item_string) + local tooltipSlot = slotTbl.selectedJewelNodeId and self.itemsTab.sockets[slotTbl.selectedJewelNodeId] or activeSlot + self.itemsTab:AddItemTooltip(tooltip, item, tooltipSlot, true) + addMegalomaniacCompareToTooltipIfApplicable(tooltip, self.itemIndexTbl[row_idx]) end controls["importButton"..row_idx].enabled = function() - return self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]].item_string ~= nil + local selectedResult = getSelectedResult() + return selectedResult and selectedResult.item_string ~= nil or false end -- Whisper so we can copy to clipboard - controls["whisperButton" .. row_idx] = new("ButtonControl"):ButtonControl({ "TOPLEFT", controls["importButton" .. row_idx], "TOPRIGHT" }, { 8, 0, 155, row_height }, function() - local itemResult = self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]] + controls["whisperButton" .. row_idx] = new("ButtonControl"):ButtonControl( + { "TOPLEFT", controls["importButton" .. row_idx], "TOPRIGHT" }, { 8, 0, 155, row_height }, function() + local itemResult = getSelectedResult() if not itemResult then return "" end @@ -1256,23 +1724,14 @@ 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 - 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)) @@ -1284,7 +1743,10 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite controls["whisperButton" .. row_idx].tooltipFunc = function(tooltip) tooltip:Clear() tooltip.center = true - local itemResult = self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]] + local itemResult = getSelectedResult() + if not itemResult then + return + end local text = itemResult.whisper and "Copies the item purchase whisper to the clipboard" or "Opens the search page to show the item" tooltip:AddLine(16, text) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 9b7e3f5b40..d38e635cc2 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 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 @@ -758,6 +770,7 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) options = options, slot = slot, requiredMods = options.requiredMods, + resistanceCapShortfallByType = resistanceCapShortfallByType, } -- 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.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) @@ -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 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.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, shortfallByType.Chaos) + else + for _, resistanceType in ipairs(resistanceTypes) do + addResistanceMinimum(resistancePseudoIds[resistanceType], shortfallByType[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, { + includeResistSwaps = options.includeResistSwaps == 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.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 + 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 + -- 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.includeResistSwaps then + self.lastIncludeResistSwaps = controls.includeResistSwaps.state + options.includeResistSwaps = controls.includeResistSwaps.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 b006712380..133b0c5f1b 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,9 @@ 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 = {} t_insert(rawLines, "Rarity: " .. item.rarity) @@ -344,6 +348,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 +374,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 +383,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 0000000000..11d7eab67a --- /dev/null +++ b/src/Classes/TradeResistanceGrouping.lua @@ -0,0 +1,115 @@ +-- 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.getResistanceCapShortfallByType(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 + +-- 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 + + 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 0000000000..38e41055e3 --- /dev/null +++ b/src/Classes/TradeResistanceSwap.lua @@ -0,0 +1,227 @@ +-- Path of Building +-- +-- Module: Trade Resistance Swap +-- Extracts safe resistance-swap metadata and builds estimated 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 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 = 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 == "" + 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 = 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%.", "") + 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 + -- 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 + + 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 = getSingleModMetadata(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, + }) + 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 = {} + -- 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 = {} + 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.itemMatchesSwapDescriptors(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"):Item(itemString) + if not M.itemMatchesSwapDescriptors(item, descriptors) then + return + end + local swaps = {} + local swappedLineIndexes = {} + 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 }) + table.insert(swappedLineIndexes, descriptor.lineIndex) + end + end + item:BuildAndParseRaw() + return item, swaps, swappedLineIndexes +end + +return M