diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index e11ea701b9..1610eecd2d 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,281 @@ 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 + + local function weight(id, value, meanStatDiff) + return { tradeModId = id, weight = value, meanStatDiff = meanStatDiff or value, invert = false } + end + + local function filtersById(query, groupType) + local filters = { } + for _, group in ipairs(query.query.stats) do + if not groupType or group.type == groupType then + for _, filter in ipairs(group.filters) do + filters[filter.id] = filter + end + end + end + return filters + end + + local function minimumsById(query) + local minimums = { } + for id, filter in pairs(filtersById(query, "and")) do + minimums[id] = filter.value.min + end + return minimums + end + + it("groups resistance without changing damage filters", function() + local query = finishQuery({ includeResistSwaps = true }, { + annotatedWeight("explicit.fire_resistance", "+#% to Fire Resistance", 10, 10), + weight("explicit.fire_damage", 8), + weight("explicit.life", 6), + }) + local filters = filtersById(query, "weight") + + assert.is_not_nil(filters["pseudo.pseudo_total_elemental_resistance"]) + assert.is_not_nil(filters["explicit.fire_damage"]) + assert.is_not_nil(filters["explicit.life"]) + assert.is_nil(filters["explicit.fire_resistance"]) + end) + + it("leaves non-swappable resistance filters unchanged", function() + local cases = { + { "hybrid elemental and chaos", "explicit.hybrid_resistance", "+#% to Fire and Chaos Resistances" }, + { "implicit resistance", "implicit.fire_resistance", "+#% to Fire Resistance" }, + } + for _, case in ipairs(cases) do + local query = finishQuery({ includeResistSwaps = true }, { annotatedWeight(case[2], case[3], 10, 10) }) + local filters = query.query.stats[1].filters + assert.are.equal(1, #filters, case[1]) + assert.are.equal(case[2], filters[1].id, case[1]) + end + 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, weight(string.format("explicit.filler_%d", index), 100 - index)) + end + table.insert(weights, weight("explicit.low_priority_filter", 1)) + + local query = finishQuery({ includeResistSwaps = true }, weights) + local filters = filtersById(query, "weight") + + assert.are.equal(33, #query.query.stats[1].filters) + assert.is_not_nil(filters["explicit.hybrid_resistance"]) + assert.is_not_nil(filters["explicit.low_priority_filter"]) + end) + + it("does not persist the swap option into requester context", function() + local _, slotTable, queryOptions = finishQuery({ includeResistSwaps = true }, { + weight("explicit.life", 6), + }) + + 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("uses individual or grouped cap minimums according to the swap option", function() + local shortfalls = { Fire = 10, Cold = 20, Lightning = 30, Chaos = 40 } + local cases = { + { label = "caps only", options = { includeResistCaps = true }, weights = { + 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), + weight("explicit.life", 6), + }, minimums = { + ["pseudo.pseudo_total_fire_resistance"] = 10, + ["pseudo.pseudo_total_cold_resistance"] = 20, + ["pseudo.pseudo_total_lightning_resistance"] = 30, + ["pseudo.pseudo_total_chaos_resistance"] = 40, + } }, + { label = "caps with swaps", options = { includeResistCaps = true, includeResistSwaps = true }, weights = { + annotatedWeight("explicit.fire_resistance", "+#% to Fire Resistance", 10, 10), + weight("explicit.life", 6), + }, minimums = { + ["pseudo.pseudo_total_elemental_resistance"] = 60, + ["pseudo.pseudo_total_chaos_resistance"] = 40, + } }, + } + for _, case in ipairs(cases) do + case.options.resistanceCapShortfallByType = shortfalls + local query, _, queryOptions = finishQuery(case.options, case.weights) + assert.are.same(case.minimums, minimumsById(query), case.label) + assert.are.equal(1, #query.query.stats[1].filters, case.label) + assert.are.equal("explicit.life", query.query.stats[1].filters[1].id, case.label) + assert.are.equal(0, query.query.stats[1].value.min, case.label) + assert.is_false(queryOptions.weightAdjustedSearch, case.label) + end + 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, + }, { + weight("explicit.life", 6), + }) + 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), + weight("explicit.fire_damage", 2, 20), + weight("explicit.life", 1, 10), + }) + 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..a67600df67 100644 --- a/spec/System/TestTradeQueryRequests_spec.lua +++ b/spec/System/TestTradeQueryRequests_spec.lua @@ -194,50 +194,174 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] end) describe("FetchResultBlock", function() - it("reads weighted sums from current and legacy pseudo mods", function() - local function makeTradeEntry(id, pseudoMods) - return { - id = id, - listing = { - price = { amount = 1, currency = "chaos", type = "~price" }, - whisper = "hi", - account = { name = "seller" }, - }, - item = { - pseudoMods = pseudoMods, - rarity = "Rare", - name = "Test Subject", - typeLine = "Astral Plate", - }, - } - end - local response = dkjson.encode({ - result = { - makeTradeEntry("current", { { description = "Sum: 178", domain = "pseudo", hash = "stat.statgroup.0" } }), - makeTradeEntry("legacy", { "Sum: 42" }), - makeTradeEntry("empty", { }), + 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 makeTradeEntry(id, item) + return { + id = id, + listing = { + price = { amount = 1, currency = "chaos", type = "~price" }, + whisper = "private listing text", account = { name = "private account" }, }, - }) + item = item, + } + end + + local function fetchEntries(entries) + local response = dkjson.encode({ result = entries }) local fetchedItems local callbackError - requests.requestQueue.fetch = { } + 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 + end + + local function fetchSingle(item) + return fetchEntries({ makeTradeEntry("item-id", item) })[1] + end + + it("reads weighted sums from current and legacy pseudo mods", function() + local function itemWithPseudoMods(pseudoMods) + return { pseudoMods = pseudoMods, rarity = "Rare", name = "Test Subject", typeLine = "Astral Plate" } + end + local fetchedItems = fetchEntries({ + makeTradeEntry("current", itemWithPseudoMods({ { description = "Sum: 178", domain = "pseudo", hash = "stat.statgroup.0" } })), + makeTradeEntry("legacy", itemWithPseudoMods({ "Sum: 42" })), + makeTradeEntry("empty", itemWithPseudoMods({ })), + }) local itemsById = { } for _, item in ipairs(fetchedItems) do itemsById[item.id] = item end - assert.is_nil(callbackError) assert.are.equal("178", itemsById.current.weight) 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("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 unsafe items and incomplete or ambiguous metadata", function() + local resistanceSwap = LoadModule("Classes/TradeResistanceSwap") + local function physicalTakenSibling() + return makeExplicitMod("3% of Physical Damage from Hits taken as Fire Damage", "explicit", + "explicit.phys_taken", "of Puhuarte", "S0", 3, 5) + end + local cases = { + { "shared affix group", function(item) + item.explicitMods[1].mods[1].name = "of Puhuarte" + item.explicitMods[1].mods[1].tier = "S0" + table.insert(item.explicitMods, physicalTakenSibling()) + item.extended.hashes.explicit = { + { "explicit.fire_resistance", { 0 } }, { "explicit.phys_taken", { 0 } }, + } + end }, + { "sibling hash mapping missing", function(item) + item.explicitMods[1].mods[1].name = "of Puhuarte" + item.explicitMods[1].mods[1].tier = "S0" + local sibling = physicalTakenSibling() + sibling.hash = nil + table.insert(item.explicitMods, sibling) + end }, + { "sibling group and identity missing", function(item) + local sibling = physicalTakenSibling() + sibling.hash = nil + sibling.mods[1].level = nil + table.insert(item.explicitMods, sibling) + end }, + { "fractured", function(item) item.explicitMods[1].flags = { fractured = true } end }, + { "corrupted", function(item) item.corrupted = true end }, + { "duplicated", function(item) item.duplicated = true end }, + { "mirrored", function(item) item.mirrored = true end }, + { "unmodifiable", function(item) item.unmodifiable = true end }, + { "unmodifiable except chaos", function(item) item.unmodifiableExceptChaos = true end }, + { "extended metadata missing", function(item) item.extended = nil end }, + { "affix metadata missing", function(item) item.explicitMods[1].mods = {} end }, + { "affix metadata ambiguous", function(item) table.insert(item.explicitMods[1].mods, item.explicitMods[1].mods[1]) end }, + { "tier missing", function(item) item.explicitMods[1].mods[1].tier = nil end }, + { "level missing", function(item) item.explicitMods[1].mods[1].level = nil end }, + { "magnitude missing", function(item) item.explicitMods[1].mods[1].magnitudes = {} end }, + { "hash index ambiguous", function(item) item.extended.hashes.explicit[1][2] = { 0, 1 } end }, + { "hash duplicated", function(item) table.insert(item.extended.hashes.explicit, { "explicit.fire_resistance", { 0 } }) end }, + } + for _, case in ipairs(cases) do + local item = makeStandaloneItem() + case[2](item) + assert.are.same({ }, resistanceSwap.extractDescriptors(item), case[1]) + end + end) end) describe("FetchResults", function() diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 9a83a331c4..6d23015e5d 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -6,23 +6,155 @@ describe("TradeQuery", function() mock_tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) end) - describe("result dropdown tooltipFunc", function() - -- Builds a TradeQuery with the strict minimum needed for - -- PriceItemRowDisplay to construct row 1 without exploding. Only the - -- two itemsTab subtables read by the slot lookup at the top of - -- PriceItemRowDisplay need to be created here; everything else either - -- lives behind a callback we never trigger, or is already initialized - -- by the TradeQuery constructor. - local function newTradeQuery(state) - local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) - tq.itemsTab.activeItemSet = {} - tq.itemsTab.slots = {} - tq.slotTables[1] = { slotName = "Ring 1" } - if state.resultTbl then tq.resultTbl = state.resultTbl end - if state.sortedResultTbl then tq.sortedResultTbl = state.sortedResultTbl end - return tq + + local function newRowQuery(state) + local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tq.itemsTab.activeItemSet = { } + tq.itemsTab.slots = { } + tq.slotTables[1] = { slotName = "Ring 1" } + tq.controls.pbNotice = { label = "" } + if state and state.resultTbl then tq.resultTbl = state.resultTbl end + if state and state.sortedResultTbl then tq.sortedResultTbl = state.sortedResultTbl end + return tq + end + + local function listedResult(itemString, evaluation, amount) + return { item_string = itemString, evaluation = evaluation, amount = amount or 1, currency = "chaos" } + end + + describe("cooperative result evaluation", function() + local function newProcessingQuery(resultCounts) + local tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tradeQuery.controls.priceButton1 = { label = "Price Item" } + tradeQuery.controls.pbNotice = { label = "" } + for rowIdx, count in ipairs(resultCounts or { }) do + tradeQuery.resultTbl[rowIdx] = { } + for _ = 1, count do + table.insert(tradeQuery.resultTbl[rowIdx], { }) + end + end + return tradeQuery end + it("resumes fetched result work over multiple frames", function() + local tradeQuery = newProcessingQuery({ 2 }) + 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) + local frames = { + { { }, "Eval 0/2..." }, + { { "first" }, "Eval 1/2..." }, + { { "first", "second" }, "Eval 2/2..." }, + { { "first", "second", "done" }, "Price Item" }, + } + for index, frame in ipairs(frames) do + if index > 1 then tradeQuery:ProcessResultEvaluations() end + assert.are.same(frame[1], events, "frame " .. index) + assert.are.equal(frame[2], tradeQuery.controls.priceButton1.label, "frame " .. index) + end + assert.is_nil(tradeQuery.resultProcessingByRow[1]) + end) + + it("clears the prior selection before scheduling a new evaluation", function() + local tradeQuery = newProcessingQuery({ 1 }) + local dropdownList + tradeQuery.controls.resultDropdown1 = { + SetList = function(_, list) + dropdownList = list + end, + } + tradeQuery.controls.fullPrice = { label = "" } + 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 = newProcessingQuery({ 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_false(evaluated) + assert.are.equal("Searching...", tradeQuery.controls.priceButton1.label) + end) + + it("rejects a response from a superseded fetch", function() + local tradeQuery = newProcessingQuery() + + 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 = newProcessingQuery({ 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.resultProcessingByRow[1]) + end) + + it("does not resume replaced queued work before another result row", function() + local tradeQuery = newProcessingQuery({ 1, 1 }) + local events = { } + tradeQuery.UpdateControlsWithItems = function(_, rowIdx, yieldFunc) + table.insert(events, rowIdx) + yieldFunc(1, 1) + table.insert(events, rowIdx) + end + + tradeQuery:StartResultEvaluation(1) + local fetchToken = tradeQuery:StartResultFetch(1) + assert.is_true(tradeQuery:FinishResultFetch(1, fetchToken)) + tradeQuery:StartResultEvaluation(1) + tradeQuery:StartResultEvaluation(2) + + tradeQuery:ProcessResultEvaluations() + tradeQuery:ProcessResultEvaluations() + + assert.are.same({ 1, 2 }, events) + end) + end) + describe("result dropdown tooltipFunc", function() -- Builds row 1 of the trader UI and returns the dropdown that owns the -- tooltipFunc we want to exercise. local function buildRow1Dropdown(tq) @@ -30,9 +162,25 @@ describe("TradeQuery", function() return tq.controls.resultDropdown1 end + local function swapEvaluation(itemString, swaps, lineIndexes) + return { { + output = { }, + weight = 1, + estimatedResistanceSwap = { swaps = swaps, itemString = itemString, lineIndexes = lineIndexes }, + } } + end + + local function tooltipText(tooltip) + local text = "" + for _, line in ipairs(tooltip.lines) do + text = text .. (line.text or "") .. "\n" + end + return text + end + it("returns early when sortedResultTbl[row_idx] is missing", function() -- No sorted results at all -> first guard must short-circuit. - local tq = newTradeQuery({}) + local tq = newRowQuery({}) local dropdown = buildRow1Dropdown(tq) local tooltip = new("Tooltip"):Tooltip() @@ -47,8 +195,8 @@ describe("TradeQuery", function() -- PriceItemRowDisplay's construction loop succeeds; we wipe -- resultTbl[1] only afterwards, to simulate a stale tooltip -- callback firing after the results were invalidated. - local tq = newTradeQuery({ - resultTbl = { [1] = { [1] = { item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring", amount = 1, currency = "chaos" } } }, + local tq = newRowQuery({ + resultTbl = { [1] = { [1] = listedResult("Rarity: RARE\nBehemoth Hold\nGold Ring") } }, sortedResultTbl = { [1] = { { index = 1 } } }, }) local dropdown = buildRow1Dropdown(tq) @@ -60,6 +208,94 @@ 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 = newRowQuery({ + resultTbl = { [1] = { [1] = listedResult(itemString, swapEvaluation( + "Rarity: RARE\nBehemoth Hold\nCoral Ring\nImplicits: 0\n+17% to Cold Resistance", + { { from = "Fire", to = "Cold" } }, { 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 = tooltipText(tooltip) + 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 = newRowQuery({ + resultTbl = { [1] = { [1] = listedResult(itemString, swapEvaluation( + "Rarity: RARE\nBehemoth Hold\nCoral Ring\nImplicits: 0\n+30 to Strength\n+17% to Cold Resistance\n+24% to Lightning Resistance", + { { from = "Fire", to = "Cold" }, { from = "Cold", to = "Lightning" } }, { 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 = StripEscapes(tooltipText(tooltip.childTooltips[1])) + 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 = newRowQuery({ + resultTbl = { [1] = { listedResult("Rarity: RARE\nBehemoth Hold\nGold Ring") } }, + 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 oldResult = listedResult("Rarity: RARE\nOld Hold\nGold Ring") + local newResult = listedResult("Rarity: RARE\nNew Hold\nGold Ring", nil, 2) + local tradeQuery = newRowQuery({ + resultTbl = { [1] = { oldResult } }, 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 +350,484 @@ describe("TradeQuery", function() assert.are.equals(1.2, result) end) end) + + describe("exact listing query", function() + local function buildExact(stats, trader, weight) + local query = require("dkjson").encode({ query = { stats = stats, filters = { } } }) + return require("dkjson").decode(mock_tradeQuery:BuildExactListingQuery(query, + { trader = trader, weight = weight })) + end + + it("keeps the existing weight range narrowing for weighted queries", function() + local exact = buildExact({ { type = "weight", value = { min = 10 }, filters = { } } }, + "WeightSeller", "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 exact = buildExact({ { type = "and", + filters = { { id = "pseudo.pseudo_total_fire_resistance", value = { min = 40 } } } } }, + "CapSeller", "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("carries generated options and descriptors into scheduled evaluation", function() + local originalAuthToken = main.api.authToken + local ok, err = pcall(function() + main.api.authToken = "test-token" + local cases = { + { label = "resistance options enabled", swaps = true, caps = true, weighted = false, route = "plain" }, + { label = "resistance options disabled", swaps = false, caps = false, weighted = true, route = "adjusted" }, + } + for _, case in ipairs(cases) do + local queryOptions = { + includeResistSwaps = case.swaps, + includeResistCaps = case.caps, + weightAdjustedSearch = case.weighted, + } + local tradeQuery = newRowQuery({}) + tradeQuery.pbRealm = "pc" + tradeQuery.pbLeague = "Standard" + tradeQuery.tradeQueryGenerator = { } + tradeQuery.tradeQueryGenerator.RequestQuery = function(_, _, context, _, callback) + callback(context, case.label .. " query", nil, queryOptions) + end + + local routedRequest + local function makeSearchHandler(routeName) + return function(_, realm, league, query, callback) + routedRequest = { routeName, realm, league, query } + callback({ { + item_string = "Rarity: RARE\nTest Ring\nCoral Ring\nImplicits: 0\n+17% to Fire Resistance", + resistanceSwapDescriptors = { { lineIndex = 1, element = "Fire", domain = "explicit" } }, + } }) + end + end + tradeQuery.tradeQueryRequests = { + SearchWithQuery = makeSearchHandler("plain"), + SearchWithQueryWeightAdjusted = makeSearchHandler("adjusted"), + } + + local evaluatedResult + tradeQuery.GetResultEvaluation = function(self, rowIdx, resultIndex) + evaluatedResult = self.resultTbl[rowIdx][resultIndex] + return { { weight = 1 } } + end + tradeQuery.UpdateControlsWithItems = function(self, rowIdx) + self:GetResultEvaluation(rowIdx, 1) + end + + tradeQuery:PriceItemRowDisplay(1, nil, 0, 20) + tradeQuery.controls.bestButton1.onClick() + tradeQuery:ProcessResultEvaluations() + + local result = tradeQuery.resultTbl[1][1] + assert.are.same({ case.route, "pc", "Standard", case.label .. " query" }, + routedRequest, case.label) + assert.are.equal(case.swaps, result.resistanceSwapEnabled, case.label) + assert.are.equal(case.caps, result.prioritiseResistanceCaps, case.label) + assert.are.same({ { lineIndex = 1, element = "Fire", domain = "explicit" } }, + result.resistanceSwapDescriptors, case.label) + assert.are.equal(result, evaluatedResult, case.label) + end + end) + main.api.authToken = originalAuthToken + assert.is_true(ok, err) + end) + end) + + describe("resistance swap result evaluation", function() + local function resistance(value, element, options) + options = options or { } + local domain = options.domain or "explicit" + return { + line = (domain == "crafted" and "{crafted}" or "") + .. string.format("+%d%% to %s Resistance", value, element), + descriptor = options.descriptor ~= false + and { element = options.descriptorElement or element, domain = domain } or nil, + } + end + + local function newEvaluationQuery(mods, options) + options = options or { } + local lines = { } + local descriptors = { } + for lineIndex, mod in ipairs(mods) do + local line = type(mod) == "string" and mod or mod.line + table.insert(lines, line) + if type(mod) == "table" and mod.descriptor then + table.insert(descriptors, { lineIndex = lineIndex, + element = mod.descriptor.element, domain = mod.descriptor.domain }) + end + end + 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 = "Rarity: RARE\nTest Ring\nCoral Ring\nImplicits: 0\n" .. table.concat(lines, "\n"), + resistanceSwapDescriptors = #descriptors > 0 and descriptors or nil, + resistanceSwapEnabled = options.swaps == true, + prioritiseResistanceCaps = options.caps == true, + } } + return tq + end + + local function elementCalculator(multipliers, requirements, onEvaluation) + return function(args) + local totals = { Fire = 0, Cold = 0, Lightning = 0, Chaos = 0 } + local seen = { } + for _, modLine in ipairs(args.repItem.explicitModLines) do + local value, element = modLine.line:match("^%+(%d+)%% to (%a+) Resistance$") + if value and totals[element] then + assert.is_nil(seen[element], "duplicate resistance target " .. element) + seen[element] = true + totals[element] = tonumber(value) + end + end + local score = 0 + for element, total in pairs(totals) do + score = score + total * ((multipliers and multipliers[element]) or 0) + end + if onEvaluation then onEvaluation() end + local output = { Life = 100 + score } + for element, total in pairs(totals) do + output["Missing" .. element .. "Resist"] = math.max(0, + ((requirements and requirements[element]) or 0) - total) + end + return output + end + end + + local function evaluate(tq, calc, yieldFunc) + return tq:GetResultEvaluation(1, 1, calc, { Life = 100 }, yieldFunc) + end + + local function attachCalculator(tq, calc, baseOutput) + tq.itemsTab.build = { calcsTab = { GetMiscCalculator = function() + local output = type(baseOutput) == "function" and baseOutput() or baseOutput + return calc, output or { Life = 100 } + end } } + end + + local function resistanceState(fireTotal) + local output = { Life = 100 } + for _, element in ipairs({ "Fire", "Cold", "Lightning", "Chaos" }) do + output[element .. "Resist"] = 75 + output[element .. "ResistTotal"] = 75 + output["Missing" .. element .. "Resist"] = 0 + end + output.FireResistTotal = fireTotal + return output + end + + it("uses only the listed item when it is already capped and resistance state is irrelevant", function() + local tq = newEvaluationQuery({ resistance(10, "Fire"), resistance(20, "Cold") }, + { swaps = true, caps = true }) + local calls = 0 + tq.ResistanceSwapMayAffectOutput = function() return false end + + local evaluation = evaluate(tq, function() + calls = calls + 1 + return { + Life = 100, + MissingFireResist = 0, + MissingColdResist = 0, + MissingLightningResist = 0, + MissingChaosResist = 0, + } + end) + + assert.are.equal(1, calls) + assert.are.equal(1, #evaluation) + assert.is_nil(evaluation[1].estimatedResistanceSwap) + end) + + it("only evaluates swaps that can feed an elemental resistance deficit", function() + local tq = newEvaluationQuery({ resistance(10, "Fire") }, { swaps = true, caps = true }) + local calls = 0 + tq.ResistanceSwapMayAffectOutput = function() return false end + + local evaluation = evaluate(tq, function(args) + calls = calls + 1 + return elementCalculator(nil, + { Fire = 0, Cold = 10, Lightning = 0, Chaos = 0 })(args) + end) + + assert.are.equal(2, calls) + assert.are.equal(1, #evaluation) + assert.are.equal("Cold", evaluation[1].estimatedResistanceSwap.swaps[1].to) + end) + + it("keeps resistance swaps when the build depends on resistance state", function() + local tq = newEvaluationQuery({ resistance(10, "Fire"), resistance(20, "Cold") }, + { swaps = true, caps = true }) + local calls = 0 + local calc = elementCalculator({ Fire = 1, Cold = 2, Lightning = 3 }, + { Fire = 0, Cold = 0, Lightning = 0, Chaos = 0 }) + tq.ResistanceSwapMayAffectOutput = function() return true end + + evaluate(tq, function(args) + calls = calls + 1 + return calc(args) + end) + + assert.are.equal(6, calls) + end) + + it("detects direct and modifier-based resistance output dependencies", function() + local tq = newEvaluationQuery({ resistance(10, "Fire") }, { swaps = true, caps = true }) + tq.itemsTab.build = { calcsTab = { mainEnv = { player = { + modDB = { mods = { } }, + } } } } + local function flag(name) + return { name = name, type = "FLAG" } + end + + assert.is_false(tq:ResistanceSwapMayAffectOutput()) + assert.is_true(tq:ResistanceSwapMayAffectOutput({ + modList = { flag("FirePenIncreasedByUncappedFireRes") }, + })) + assert.is_true(tq:ResistanceSwapMayAffectOutput({ + modList = { flag("DamageIncreasedByOvercappedColdRes") }, + })) + + 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 = { flag("FirePenIncreasedByUncappedFireRes") }, + } + assert.is_true(tq:ResistanceSwapMayAffectOutput()) + end) + + it("generates exactly 3, 6, and 6 distinct-target assignments for one to three candidates", function() + local resistanceSwap = LoadModule("Classes/TradeResistanceSwap") + local cases = { + { elements = { "Fire" }, expectedAssignments = 3 }, + { elements = { "Fire", "Cold" }, expectedAssignments = 6 }, + { elements = { "Fire", "Cold", "Lightning" }, expectedAssignments = 6 }, + } + for _, case in ipairs(cases) do + local descriptors = { } + for lineIndex, element in ipairs(case.elements) do + table.insert(descriptors, { lineIndex = lineIndex, element = element, domain = "explicit" }) + end + assert.are.equal(case.expectedAssignments, #resistanceSwap.getAssignments(descriptors), + table.concat(case.elements, ", ")) + end + end) + + it("provides a cooperative yield point after each calculated assignment", function() + local calls = 0 + local yields = 0 + local tq = newEvaluationQuery({ resistance(10, "Fire"), resistance(20, "Cold") }, { swaps = true }) + + evaluate(tq, + elementCalculator({ Fire = 1, Cold = 2, Lightning = 3 }, nil, + function() calls = calls + 1 end), + 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({ resistance(10, "Fire"), resistance(20, "Cold") }, { swaps = true }) + local original = tq.resultTbl[1][1].item_string + + local evaluation = evaluate(tq, elementCalculator({ Fire = 1, Cold = 2, Lightning = 4 })) + local resistanceSwap = evaluation[1].estimatedResistanceSwap + local swaps = resistanceSwap.swaps + + 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 }, resistanceSwap.lineIndexes) + assert.is_truthy(resistanceSwap.itemString:find("+10%% to Cold Resistance")) + assert.is_truthy(resistanceSwap.itemString: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({ resistance(10, "Cold") }, { swaps = true }) + + local evaluation = evaluate(tq, function() + return { Life = 100 } + end) + + assert.is_nil(evaluation[1].estimatedResistanceSwap) + end) + + it("uses one baseline calculation when ranking is disabled or ineligible", function() + local cases = { + { label = "swaps disabled", query = newEvaluationQuery({ resistance(10, "Fire") }) }, + { label = "descriptor element mismatch", query = newEvaluationQuery({ + resistance(10, "Fire", { descriptorElement = "Cold" }), + }, { swaps = true }) }, + { label = "descriptor missing", query = newEvaluationQuery({ + resistance(10, "Fire", { descriptor = false }), + }, { swaps = true }) }, + } + for _, case in ipairs(cases) do + local calls = 0 + evaluate(case.query, function() + calls = calls + 1 + return { Life = 100 } + end) + assert.are.equal(1, calls, case.label) + end + end) + + it("keeps capped and partially repaired listings", function() + local requirements = { Fire = 40, Cold = 40, Lightning = 0, Chaos = 30 } + local cases = { + { label = "already capped", mods = { + resistance(40, "Fire"), resistance(80, "Cold"), resistance(30, "Chaos", { descriptor = false }), + }, multipliers = { Fire = 1, Cold = 1, Lightning = 100 }, shortfall = 0, expectSwap = false }, + { label = "best partial assignment", mods = { + resistance(80, "Fire"), resistance(30, "Chaos", { descriptor = false }), + }, shortfall = 40 }, + } + for _, case in ipairs(cases) do + local tq = newEvaluationQuery(case.mods, { swaps = true, caps = true }) + local evaluation = evaluate(tq, elementCalculator(case.multipliers, requirements)) + assert.are.equal(1, #evaluation, case.label) + assert.are.equal(case.shortfall, evaluation[1].totalResistanceCapShortfall, case.label) + if case.expectSwap ~= nil then + assert.are.equal(case.expectSwap, evaluation[1].estimatedResistanceSwap ~= nil, case.label) + end + end + end) + + it("records elemental and Chaos shortfalls without dropping caps-only listings", function() + local calc = elementCalculator(nil, { Fire = 40, Cold = 0, Lightning = 0, Chaos = 30 }) + local cases = { + { label = "capped", fire = 40, chaos = 30, shortfall = 0 }, + { label = "elemental shortfall", fire = 39, chaos = 30, shortfall = 1 }, + { label = "Chaos shortfall", fire = 40, chaos = 29, shortfall = 1 }, + } + for _, case in ipairs(cases) do + local tq = newEvaluationQuery({ resistance(case.fire, "Fire"), resistance(case.chaos, "Chaos") }, + { caps = true }) + local evaluation = evaluate(tq, calc) + assert.are.equal(1, #evaluation, case.label) + assert.are.equal(case.shortfall, evaluation[1].totalResistanceCapShortfall, case.label) + end + 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({ resistance(40, "Fire"), resistance(30, "Chaos") }, { caps = true }) + local function calc(args) + return elementCalculator(nil, { + Fire = requiredFire, + Cold = 0, + Lightning = 0, + Chaos = 30, + })(args) + end + attachCalculator(tq, calc, function() return resistanceState(requiredFire) 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({ "+5 to Strength", resistance(10, "Fire", { domain = "crafted" }) }, + { swaps = true }) + local calc = elementCalculator({ Fire = 1, Cold = 2, Lightning = 3 }, nil, + function() calls = calls + 1 end) + attachCalculator(tq, calc) + + 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("publishes evaluation inputs only after cooperative calculation completes", function() + local tq = newEvaluationQuery({ resistance(10, "Fire") }, { swaps = true }) + local result = tq.resultTbl[1][1] + result.evaluation = { { weight = -1 } } + local co = coroutine.create(function() + tq:GetResultEvaluation(1, 1, + elementCalculator({ Fire = 1, Cold = 2, Lightning = 3 }), + { Life = 100 }, coroutine.yield) + end) + + assert.is_true(coroutine.resume(co)) + assert.are.equal(-1, result.evaluation[1].weight) + assert.is_nil(result.evaluationInputs) + while coroutine.status(co) ~= "dead" do + assert.is_true(coroutine.resume(co)) + end + assert.is_truthy(result.evaluationInputs) + assert.is_true(result.evaluation[1].weight > 0) + end) + + it("reuses the cached evaluation when sorting supplies a shared calculator", function() + local calls = 0 + local tq = newEvaluationQuery({ resistance(10, "Fire") }, { swaps = true }) + local calc = elementCalculator({ Fire = 1, Cold = 2, Lightning = 3 }, nil, + function() calls = calls + 1 end) + local baseOutput = { Life = 100 } + attachCalculator(tq, calc, baseOutput) + + 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..9a848dad32 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") @@ -31,10 +147,6 @@ function TradeQueryClass:TradeQuery(itemsTab) self.resultTbl = { } self.sortedResultTbl = { } self.itemIndexTbl = { } - -- tooltip acceleration tables - self.onlyWeightedBaseOutput = { } - self.lastComparedWeightList = { } - -- default set of trade item sort selection self.slotTables = { } self.pbItemSortSelectionIndex = 1 @@ -58,6 +170,10 @@ function TradeQueryClass:TradeQuery(itemsTab) self.backoffFinish = nil -- last query for each row self.lastQueries = {} + -- Each row has one active fetch or evaluation. The queue stores evaluation + -- contexts, so replaced work cannot resume or apply stale results. + self.resultProcessingByRow = {} + self.resultEvaluationQueue = {} self.tradeQueryRequests = new("TradeQueryRequests"):TradeQueryRequests() if not main.api then @@ -443,7 +559,10 @@ 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) + local processing = self.resultProcessingByRow[row_idx] + if not (processing and processing.fetchToken) then + self:StartResultEvaluation(row_idx) + end end end) self.controls.itemSortSelection.tooltipText = @@ -654,6 +773,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 +867,10 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) self.statSortSelectionList = statSortSelectionList end for row_idx in pairs(self.resultTbl) do - self:UpdateControlsWithItems(row_idx) + local processing = self.resultProcessingByRow[row_idx] + if not (processing and processing.fetchToken) then + self:StartResultEvaluation(row_idx) + end end end) controls.cancel = new("ButtonControl"):ButtonControl({ "BOTTOM", nil, "BOTTOM" }, { 0, -10, 80, 20 }, "Cancel", function() @@ -781,6 +904,116 @@ 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 = current and s_format("Eval %d/%d...", current, total or 0) or "Price Item" + end +end + +function TradeQueryClass:CancelResultProcessing(rowIdx) + self.resultProcessingByRow[rowIdx] = nil + self:SetResultEvaluationProgress(rowIdx) +end + +function TradeQueryClass:StartResultFetch(rowIdx) + self:CancelResultProcessing(rowIdx) + local fetchToken = { } + self.resultProcessingByRow[rowIdx] = { fetchToken = fetchToken } + local button = self.controls["priceButton" .. rowIdx] + if button then + button.label = "Searching..." + end + return fetchToken +end + +function TradeQueryClass:IsResultFetchCurrent(rowIdx, fetchToken) + local processing = self.resultProcessingByRow[rowIdx] + return processing and processing.fetchToken == fetchToken or false +end + +function TradeQueryClass:FinishResultFetch(rowIdx, fetchToken) + if not self:IsResultFetchCurrent(rowIdx, fetchToken) then + return false + end + self.resultProcessingByRow[rowIdx] = nil + self:SetResultEvaluationProgress(rowIdx) + return true +end + +function TradeQueryClass:StartResultEvaluation(rowIdx) + local processing = self.resultProcessingByRow[rowIdx] + if processing and processing.fetchToken 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 = { + rowIdx = rowIdx, + total = #results, + } + context.co = coroutine.create(function() + self:UpdateControlsWithItems(rowIdx, function(current, total) + if self.resultProcessingByRow[rowIdx] ~= context then + return + end + self:SetResultEvaluationProgress(rowIdx, current, total) + coroutine.yield() + end) + end) + self.resultProcessingByRow[rowIdx] = context + self:SetResultEvaluationProgress(rowIdx, 0, context.total) + t_insert(self.resultEvaluationQueue, context) +end + +function TradeQueryClass:ProcessResultEvaluations() + local context + repeat + context = t_remove(self.resultEvaluationQueue, 1) + if not context then + return + end + until self.resultProcessingByRow[context.rowIdx] == context + local rowIdx = context.rowIdx + + local ok, errMsg = coroutine.resume(context.co) + if not ok then + if self.resultProcessingByRow[rowIdx] == context then + self.resultProcessingByRow[rowIdx] = nil + self:SetResultEvaluationProgress(rowIdx) + 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.resultProcessingByRow[rowIdx] ~= context then + return + end + if coroutine.status(context.co) == "dead" then + self.resultProcessingByRow[rowIdx] = nil + self:SetResultEvaluationProgress(rowIdx) + else + t_insert(self.resultEvaluationQueue, context) + 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,57 +1028,154 @@ 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) + local resistanceStateSnapshot = result.prioritiseResistanceCaps and getResistanceStateSnapshot(baseOutput) + local evaluationInputs = result.evaluationInputs + if result.evaluation and evaluationInputs + and tableDeepEquals(onlyWeightedBaseOutput, evaluationInputs.weightedBaseOutput) + and tableDeepEquals(self.statSortSelectionList, evaluationInputs.statWeights) + and (not result.prioritiseResistanceCaps or tableDeepEquals(resistanceStateSnapshot, evaluationInputs.resistanceState)) then + return result.evaluation end local slotTbl = self.slotTables[row_idx] local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId local slotName = jewelNodeId and "Jewel " .. tostring(jewelNodeId) or slotTbl.slotName + local evaluation if slotName == "Megalomaniac" then local addedNodes = {} 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 local weight23 = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, output23, self.statSortSelectionList) / 4 local weight123 = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, output123, self.statSortSelectionList) / 5 - result.evaluation = { + evaluation = { { output = output12, weight = weight12, DNs = { addedNodes[1].dn, addedNodes[2].dn } }, { output = output13, weight = weight13, DNs = { addedNodes[1].dn, addedNodes[3].dn } }, { output = output23, weight = weight23, DNs = { addedNodes[2].dn, addedNodes[3].dn } }, { output = output123, weight = weight123, DNs = { addedNodes[1].dn, addedNodes[2].dn, addedNodes[3].dn } }, } - table.sort(result.evaluation, function(a, b) return a.weight > b.weight end) + table.sort(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.estimatedResistanceSwap = { + swaps = swaps, + itemString = variant:BuildRaw(), + lineIndexes = swappedLineIndexes, + } + end + end + end + end + evaluation = { bestEvaluation } end - return result.evaluation + result.evaluation = evaluation + result.evaluationInputs = { + weightedBaseOutput = onlyWeightedBaseOutput, + statWeights = self.statSortSelectionList, + resistanceState = resistanceStateSnapshot, + } + return evaluation end -- Method to update controls after a search is completed @@ -866,6 +1196,7 @@ function TradeQueryClass:UpdateDropdownList(row_idx) self.controls["resultDropdown".. row_idx]:SetList(dropdownLabels) end function TradeQueryClass:ResetResultRow(rowIdx) + self:CancelResultProcessing(rowIdx) self.itemIndexTbl[rowIdx] = nil self.sortedResultTbl[rowIdx] = nil self.resultTbl[rowIdx] = nil @@ -873,12 +1204,12 @@ function TradeQueryClass:ResetResultRow(rowIdx) self:UpdateDropdownList(rowIdx) self.controls.fullPrice.label = "^7Total Price: " .. self:GetTotalPriceString() end -function TradeQueryClass:UpdateControlsWithItems(row_idx) +function TradeQueryClass:UpdateControlsWithItems(row_idx, yieldFunc) local sortMode = self.itemSortSelectionList[self.pbItemSortSelectionIndex] - local sortedItems, errMsg = self:SortFetchResults(row_idx, sortMode) + local sortedItems, errMsg = self:SortFetchResults(row_idx, sortMode, yieldFunc) if errMsg == "MissingConversionRates" then self:SetNotice(self.controls.pbNotice, "^4Currency rates unavailable. Falling back to Stat Value sort.") - sortedItems, errMsg = self:SortFetchResults(row_idx, self.sortModes.StatValue) + sortedItems, errMsg = self:SortFetchResults(row_idx, self.sortModes.StatValue, yieldFunc) elseif errMsg then self:SetNotice(self.controls.pbNotice, "Error: " .. errMsg) return @@ -915,18 +1246,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 evaluationsByResultIndex = { } + local function getResultEvaluation(result_index) + if evaluationsByResultIndex[result_index] then + return evaluationsByResultIndex[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 + evaluationsByResultIndex[result_index] = self:GetResultEvaluation( + row_idx, result_index, calcFunc, baseOutput, yieldAfterCalculation) + return evaluationsByResultIndex[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 +1295,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 +1322,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 +1355,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 +1394,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:CancelResultProcessing(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 +1408,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 +1441,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 +1462,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 +1508,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 +1525,19 @@ 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 processing = self.resultProcessingByRow[row_idx] + local isSearching = processing and processing.fetchToken ~= nil + local isEvaluating = processing and processing.co ~= 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() @@ -1188,8 +1572,52 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite for i = 2, #nodeDNs do nodeCombo = nodeCombo .. " ^8+^7 " .. nodeDNs[i] end - self.itemsTab.build:AddStatComparesToTooltip(tooltip, self.onlyWeightedBaseOutput[row_idx][result_index], evaluationEntry.output, "^8Allocating ^7"..nodeCombo.."^8 will give You:", #nodeDNs + 2) + local evaluationInputs = result.evaluationInputs or { } + self.itemsTab.build:AddStatComparesToTooltip(tooltip, evaluationInputs.weightedBaseOutput or { }, 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 resistanceSwap = evaluation and evaluation.estimatedResistanceSwap + local swaps = resistanceSwap and resistanceSwap.swaps + 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 = resistanceSwap.itemString and colorCodes.TIP .. " [Ctrl: compare]" or "" + tooltip:AddSeparator(10) + tooltip:AddLine(16, "^7" .. label .. table.concat(descriptions, ", ") .. "^8" .. rollNote .. compareHint) + return resistanceSwap + end + local function addResistanceSwapPreviewIfApplicable(tooltip, resistanceSwap, tooltipSlot) + if not resistanceSwap or not resistanceSwap.itemString or not self:IsResistanceSwapPreviewActive() then + return + end + local previewItem = new("Item"):Item(resistanceSwap.itemString) + 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(resistanceSwap.lineIndexes 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] @@ -1206,11 +1634,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 resistanceSwap = addResistanceSwapToTooltipIfApplicable(tooltip, result) + addResistanceSwapPreviewIfApplicable(tooltip, resistanceSwap, 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 +1665,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 +1697,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 +1716,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