From 8312203cd84c3b2c53d12fad1f8753bb1ae43ab8 Mon Sep 17 00:00:00 2001 From: Lawlzer Date: Mon, 13 Jul 2026 11:41:40 -0500 Subject: [PATCH] Add scoring breakdown to Trader result dropdown --- spec/System/TestTradeQuery_spec.lua | 74 ++++++++++++++++++- src/Classes/DropDownControl.lua | 27 ++++++- src/Classes/TradeQuery.lua | 111 +++++++++++++++++++++++++--- 3 files changed, 198 insertions(+), 14 deletions(-) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 50beed755e..d208cbf0b7 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -43,4 +43,76 @@ describe("TradeQuery", function () assert.are.equals(1.2, result) end) end) -end) \ No newline at end of file + + describe("ComputeStatDetails", function() + it("uses Trader's FullDPS fallback inputs", function() + mock_tradeQuery.statSortSelectionList = { { label = "Full DPS", stat = "FullDPS", weightMult = 1 } } + + local result = mock_tradeQuery:ComputeStatDetails({ + CombinedDPS = 100, + TotalDPS = 100, + TotalDotDPS = 0, + }, { + CombinedDPS = 100, + TotalDPS = 200, + TotalDotDPS = 0, + }) + + assert.are.equals(50, result[1].percentChange) + end) + + it("reports lower-is-better stat improvements as positive", function() + mock_tradeQuery.statSortSelectionList = { + { + label = "Taken Phys dmg", + stat = "PhysicalTakenHit", + weightMult = 1, + transform = function(value) return -value end, + }, + } + + local result = mock_tradeQuery:ComputeStatDetails({ PhysicalTakenHit = 100 }, { PhysicalTakenHit = 80 }) + + assert.is_true(math.abs(result[1].percentChange - 20) < 0.0001) + end) + + it("caps displayed increases to Trader's scoring maximum", function() + mock_tradeQuery.statSortSelectionList = { { label = "Life", stat = "Life", weightMult = 1 } } + local maxStatIncrease = data.misc.maxStatIncrease + + local result = mock_tradeQuery:ComputeStatDetails({ Life = 1 }, { Life = maxStatIncrease + 1 }) + + assert.are.equals((maxStatIncrease - 1) * 100, result[1].percentChange) + end) + + it("reports unchanged zero-value stats as unchanged", function() + mock_tradeQuery.statSortSelectionList = { { label = "Block Chance", stat = "BlockChance", weightMult = 1 } } + + local result = mock_tradeQuery:ComputeStatDetails({ BlockChance = 0 }, { BlockChance = 0 }) + + assert.are.equals(0, result[1].percentChange) + end) + + it("reports improvements from zero as positive", function() + mock_tradeQuery.statSortSelectionList = { { label = "Block Chance", stat = "BlockChance", weightMult = 1 } } + local maxStatIncrease = data.misc.maxStatIncrease + + local result = mock_tradeQuery:ComputeStatDetails({ BlockChance = 0 }, { BlockChance = 0.5 }) + + assert.are.equals((maxStatIncrease - 1) * 100, result[1].percentChange) + end) + end) + + describe("GetResultScorePercent", function() + it("returns the weighted average stat delta", function() + local result = mock_tradeQuery:GetResultScorePercent({ + statDetails = { + { percentChange = 10, weightMult = 1 }, + { percentChange = -10, weightMult = 0.5 }, + }, + }) + + assert.is_true(math.abs(result - (10 / 3)) < 0.0001) + end) + end) +end) diff --git a/src/Classes/DropDownControl.lua b/src/Classes/DropDownControl.lua index cc7d47161f..0de095800b 100644 --- a/src/Classes/DropDownControl.lua +++ b/src/Classes/DropDownControl.lua @@ -8,6 +8,12 @@ local m_min = math.min local m_max = math.max local m_floor = math.floor +local function drawStrikethrough(label, y, lineHeight) + local strWidth = DrawStringWidth(lineHeight, "VAR", label or "") + SetDrawColor(0.6, 0.6, 0.6) + DrawImage(nil, 0, y + lineHeight / 2, strWidth, 1) +end + local DropDownClass = newClass("DropDownControl", "Control", "ControlHost", "TooltipHost", "SearchHost", function(self, anchor, rect, list, selFunc, tooltipText) self.Control(anchor, rect) self.ControlHost() @@ -304,6 +310,7 @@ function DropDownClass:Draw(viewPort, noTooltip) -- draw selected label or search term local selLabel = nil local selDetail = nil + local selStrikethrough = false if self:IsSearchActive() then selLabel = "Search: " .. self:GetSearchTermPretty() else @@ -311,12 +318,18 @@ function DropDownClass:Draw(viewPort, noTooltip) if type(selItem) == "table" then selLabel = selItem.label selDetail = selItem.detail + selStrikethrough = selItem.strikethrough else selLabel = selItem end end SetViewport(x + 2, y + 2, width - height, lineHeight) DrawString(0, 0, "LEFT", lineHeight, "VAR", selLabel or "") + if selStrikethrough then + drawStrikethrough(selLabel, 0, lineHeight) + local textColor = enabled and 1 or 0.66 + SetDrawColor(textColor, textColor, textColor) + end if selDetail ~= nil then local dx = DrawStringWidth(lineHeight, "VAR", selDetail) DrawString(width - dx - 22, 0, "LEFT", lineHeight, "VAR", selDetail) @@ -376,6 +389,14 @@ function DropDownClass:Draw(viewPort, noTooltip) label = listVal end DrawString(0, y, "LEFT", lineHeight, "VAR", label) + if type(listVal) == "table" and listVal.strikethrough then + drawStrikethrough(label, y, lineHeight) + if index == self.hoverSel or index == self.selIndex then + SetDrawColor(1, 1, 1) + else + SetDrawColor(0.66, 0.66, 0.66) + end + end if detail ~= nil then local detail = listVal.detail dx = DrawStringWidth(lineHeight, "VAR", detail) @@ -515,11 +536,15 @@ function DropDownClass:CheckDroppedWidth(enable) -- do not be smaller than the created width local dWidth = self.width for _, line in ipairs(self.list) do + local detailWidth = 0 if type(line) == "table" then + if line.detail then + detailWidth = DrawStringWidth(lineHeight, "VAR", line.detail) + 10 + end line = line.label or "" end -- +10 to stop clipping - dWidth = m_max(dWidth, DrawStringWidth(lineHeight, "VAR", line or "") + 10) + dWidth = m_max(dWidth, DrawStringWidth(lineHeight, "VAR", line or "") + detailWidth + 10) end -- no greater than self.maxDroppedWidth self.droppedWidth = m_min(dWidth + scrollWidth, self.maxDroppedWidth) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 261457739e..8be433fc10 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -790,6 +790,64 @@ function TradeQueryClass:ReduceOutput(output) return smallOutput end +local function getTradeStatValue(output, statTable, useFullDpsFallback) + if useFullDpsFallback then + return data.powerStatList.GetFromOutput(output, { stat = "TotalDPS" }, true) + + data.powerStatList.GetFromOutput(output, { stat = "TotalDotDPS" }, true) + + data.powerStatList.GetFromOutput(output, { stat = "CombinedDPS" }, true) + end + return data.powerStatList.GetFromOutput(output, statTable, true) +end + +local function getTradeStatRatio(baseOutput, newOutput, statTable) + local useFullDpsFallback = statTable.stat == "FullDPS" and not (baseOutput.FullDPS and newOutput.FullDPS) + local baseStat = getTradeStatValue(baseOutput, statTable, useFullDpsFallback) + local newStat = getTradeStatValue(newOutput, statTable, useFullDpsFallback) + if baseStat == math.huge then + return newStat == math.huge and 1 or 0 + elseif newStat == math.huge then + return data.misc.maxStatIncrease + elseif baseStat == 0 then + if newStat == 0 then + return 1 + end + return newStat > 0 and data.misc.maxStatIncrease or 0 + end + return m_min(newStat / ((baseStat ~= 0) and baseStat or 1), data.misc.maxStatIncrease) +end + +function TradeQueryClass:ComputeStatDetails(baseOutput, newOutput) + local details = {} + for _, statTable in ipairs(self.statSortSelectionList) do + local statRatio = getTradeStatRatio(baseOutput, newOutput, statTable) + local percentChange = (statRatio - 1) * 100 + if statTable.transform then + percentChange = (statTable.transform(statRatio) - statTable.transform(1)) * 100 + end + t_insert(details, { + label = statTable.label, + stat = statTable.stat, + percentChange = percentChange, + weightMult = statTable.weightMult or 0, + }) + end + return details +end + +function TradeQueryClass:GetResultScorePercent(evaluation) + if not evaluation or not evaluation.statDetails then + return nil + end + local totalWeight = 0 + local scorePercent = 0 + for _, detail in ipairs(evaluation.statDetails) do + local weightMult = detail.weightMult or 0 + totalWeight = totalWeight + weightMult + scorePercent = scorePercent + (detail.percentChange or 0) * weightMult + end + return totalWeight > 0 and scorePercent / totalWeight or nil +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] @@ -822,15 +880,19 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba end end - local output = self:ReduceOutput(calcFunc({ addNodes = addedNodes })) + local fullNewOutput = calcFunc({ addNodes = addedNodes }) + local output = self:ReduceOutput(fullNewOutput) local weight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, output, self.statSortSelectionList) - result.evaluation = {{ output = output, weight = weight }} + local statDetails = self:ComputeStatDetails(baseOutput, fullNewOutput) + result.evaluation = {{ output = output, weight = weight, statDetails = statDetails }} else local item = new("Item", result.item_string) - local output = self:ReduceOutput(calcFunc({ repSlotName = slotName, repItem = item })) + local fullNewOutput = calcFunc({ repSlotName = slotName, repItem = item }) + local output = self:ReduceOutput(fullNewOutput) local weight = self.tradeQueryGenerator.WeightedRatioOutputs(baseOutput, output, self.statSortSelectionList) - result.evaluation = {{ output = output, weight = weight }} + local statDetails = self:ComputeStatDetails(baseOutput, fullNewOutput) + result.evaluation = {{ output = output, weight = weight, statDetails = statDetails }} end return result.evaluation end @@ -838,19 +900,28 @@ end -- Method to update controls after a search is completed function TradeQueryClass:UpdateDropdownList(row_idx) local dropdownLabels = {} + local dropdown = self.controls["resultDropdown".. row_idx] - if not self.resultTbl[row_idx] then return end - - for result_index = 1, #self.resultTbl[row_idx] do + if not dropdown or not self.resultTbl[row_idx] or not self.sortedResultTbl[row_idx] then return end + for result_index = 1, #self.sortedResultTbl[row_idx] do local pb_index = self.sortedResultTbl[row_idx][result_index].index local result = self.resultTbl[row_idx][pb_index] - local price = string.format(" %s(%d %s)", colorCodes["CURRENCY"], result.amount, result.currency) - local item = new("Item", result.item_string) - table.insert(dropdownLabels, colorCodes[item.rarity] .. item.name .. price) + if result then + local price = s_format(" %s(%s %s)", colorCodes["CURRENCY"], tostring(result.amount), result.currency) + local item = new("Item", result.item_string) + local eval = self:GetResultEvaluation(row_idx, pb_index) + local scorePercent = eval and self:GetResultScorePercent(eval[1]) + local scoreDetail = scorePercent and s_format("%s%+.1f%%", scorePercent >= 0 and colorCodes.POSITIVE or colorCodes.NEGATIVE, scorePercent) + t_insert(dropdownLabels, { + label = colorCodes[item.rarity] .. item.name .. price, + detail = scoreDetail, + strikethrough = scorePercent and scorePercent < 0, + }) + end end - self.controls["resultDropdown".. row_idx].selIndex = 1 - self.controls["resultDropdown".. row_idx]:SetList(dropdownLabels) + dropdown.selIndex = 1 + dropdown:SetList(dropdownLabels) end function TradeQueryClass:ResetResultRow(rowIdx) self.itemIndexTbl[rowIdx] = nil @@ -1176,6 +1247,8 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite self.itemIndexTbl[row_idx] = self.sortedResultTbl[row_idx][index].index self:SetFetchResultReturn(row_idx, self.itemIndexTbl[row_idx]) end) + controls["resultDropdown"..row_idx].enableDroppedWidth = true + controls["resultDropdown"..row_idx].maxDroppedWidth = 600 self:UpdateDropdownList(row_idx) controls["resultDropdown"..row_idx].tooltipFunc = function(tooltip, dropdown_mode, dropdown_index, dropdown_display_string) local sortedRow = self.sortedResultTbl[row_idx] @@ -1192,6 +1265,20 @@ 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) tooltip:AddSeparator(10) + local eval = result.evaluation + if eval and eval[1] and eval[1].statDetails then + local scorePercent = self:GetResultScorePercent(eval[1]) + local scoreColor = scorePercent and scorePercent >= 0 and colorCodes.POSITIVE or colorCodes.NEGATIVE + tooltip:AddLine(16, "^7Score Breakdown:") + for _, detail in ipairs(eval[1].statDetails) do + local color = detail.percentChange >= 0 and colorCodes.POSITIVE or colorCodes.NEGATIVE + tooltip:AddLine(16, s_format(" %s%s: %+.1f%%^7 (weight: %.2f)", color, detail.label, detail.percentChange, detail.weightMult)) + end + if scorePercent then + tooltip:AddLine(16, s_format(" %sOverall: %+.1f%%", scoreColor, scorePercent)) + end + tooltip:AddSeparator(10) + end tooltip:AddLine(16, string.format("^7Price: %s %s", result.amount, result.currency)) end controls["importButton"..row_idx] = new("ButtonControl", { "TOPLEFT", controls["resultDropdown"..row_idx], "TOPRIGHT"}, {8, 0, 100, row_height}, "Import Item", function()