diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index befb96a6570..a7e937b4d78 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -35,6 +35,72 @@ describe("TradeQueryGenerator", function() local result = mock_queryGen.WeightedRatioOutputs(baseOutput, newOutput, statWeights) assert.are.equal(result, 100) end) + it("uses minion output for non-FullDPS stats when minion output is desired", function() + local baseOutput = { Life = 10, Minion = { Life = 100 } } + local newOutput = { Life = 10, Minion = { Life = 250 } } + local statWeights = { { stat = "MinionLife", weightMult = 1 } } + data.misc.maxStatIncrease = 1000 + + local result = mock_queryGen.WeightedRatioOutputs(baseOutput, newOutput, statWeights) + + assert.are.equal(result, 2.5) + end) + + it("uses lower is better stats correctly", function() + local baseOutput = { MaxHit = 100 } + local newOutput = { MaxHit = 10 } + local statWeights = { { stat = "MaxHit", weightMult = 1, transform = function(number) return -number end } } + data.misc.maxStatIncrease = 1000 + + local result = mock_queryGen.WeightedRatioOutputs(baseOutput, newOutput, statWeights) + + local close_enough = math.abs(result - -0.1) < 0.0001 + assert.True(close_enough) + end) + + it("uses player and minion output for FullDPS", function() + -- minion output gets assigned to the player's full dps in reality + local baseOutput = { FullDPS = 100, Minion = { FullDPS = 100 } } + local newOutput = { FullDPS = 250, Minion = { FullDPS = 1000 } } + local statWeights = { { stat = "FullDPS", weightMult = 1 } } + data.misc.maxStatIncrease = 1000 + + local result = mock_queryGen.WeightedRatioOutputs(baseOutput, newOutput, statWeights) + + assert.are.equal(result, 2.5) + end) + + it("uses player output for non-FullDPS even when minion output is available", function() + local baseOutput = { Life = 100, Minion = { Life = 100 } } + local newOutput = { Life = 250, Minion = { Life = 1000 } } + local statWeights = { { stat = "Life", weightMult = 1 } } + data.misc.maxStatIncrease = 1000 + + local result = mock_queryGen.WeightedRatioOutputs(baseOutput, newOutput, statWeights) + assert.are.equal(result, 2.5) + end) + + it("uses the fallback DPS ratio once when FullDPS is unavailable", function() + local baseOutput = { Minion = { TotalDPS = 10, TotalDotDPS = 0, CombinedDPS = 10 } } + local newOutput = { Minion = { TotalDPS = 25, TotalDotDPS = 0, CombinedDPS = 25 } } + local statWeights = { { stat = "FullDPS", weightMult = 1 } } + data.misc.maxStatIncrease = 1000 + + local result = mock_queryGen.WeightedRatioOutputs(baseOutput, newOutput, statWeights) + + assert.are.equal(result, 2.5) + end) + + it("falls back to player output when the selected stat is not on minion output", function() + local baseOutput = { Spirit = 100, Minion = { AverageDamage = 100 } } + local newOutput = { Spirit = 120, Minion = { AverageDamage = 100 } } + local statWeights = { { stat = "Spirit", weightMult = 1 } } + data.misc.maxStatIncrease = 1000 + + local result = mock_queryGen.WeightedRatioOutputs(baseOutput, newOutput, statWeights) + + assert.are.equal(result, 1.2) + end) end) describe("Filter prioritization", function() diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 332374a8395..31a62e94ded 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -1,4 +1,11 @@ describe("TradeQuery", function() + local mock_tradeQuery + local mock_queryGen + + before_each(function() + mock_tradeQuery = new("TradeQuery", { itemsTab = {} }) + mock_queryGen = new("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 @@ -54,4 +61,41 @@ describe("TradeQuery", function() assert.are.equal(0, #tooltip.lines) end) end) + describe("ReduceOutput", function() + it("uses selected minion stats for weighted result comparison", function() + mock_tradeQuery.statSortSelectionList = { { stat = "AverageDamage" } } + + local result = mock_tradeQuery:ReduceOutput({ + AverageDamage = 10, + Life = 100, + Minion = { + AverageDamage = 250, + Life = 200, + }, + }) + + assert.are.equals(260, result.AverageDamage) + assert.is_nil(result.Life) + end) + + it("keeps fallback DPS stats when FullDPS is selected but not present", function() + mock_tradeQuery.statSortSelectionList = { { stat = "FullDPS", weightMult = 1 } } + + local baseOutput = { + CombinedDPS = 100, + TotalDPS = 100, + TotalDotDPS = 0, + } + local reducedOutput = mock_tradeQuery:ReduceOutput({ + CombinedDPS = 120, + TotalDPS = 120, + TotalDotDPS = 0, + }) + + local result = mock_queryGen.WeightedRatioOutputs(baseOutput, reducedOutput, + mock_tradeQuery.statSortSelectionList) + + assert.are.equals(1.2, result) + end) + end) end) diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index 690103ba096..a7139797377 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -712,16 +712,8 @@ function CalcsTabClass:PowerBuilder() end function CalcsTabClass:CalculatePowerStat(selection, original, modified) - if modified.Minion and selection.stat ~= "FullDPS" then - original = original.Minion - modified = modified.Minion - end - local originalValue = original[selection.stat] or 0 - local modifiedValue = modified[selection.stat] or 0 - if selection.transform then - originalValue = selection.transform(originalValue) - modifiedValue = selection.transform(modifiedValue) - end + local originalValue = data.powerStatList.GetFromOutput(original, selection) + local modifiedValue = data.powerStatList.GetFromOutput(modified, selection) return originalValue - modifiedValue end @@ -732,10 +724,9 @@ function CalcsTabClass:CalculateCombinedOffDefStat(original, modified) (original.Evasion - modified.Evasion) / m_max(10000, modified.Evasion) + (original.LifeRegenRecovery - modified.LifeRegenRecovery) / 500 + (original.EnergyShieldRegenRecovery - modified.EnergyShieldRegenRecovery) / 1000 - if modified.Minion then - return (original.Minion.CombinedDPS - modified.Minion.CombinedDPS) / modified.Minion.CombinedDPS, defence - end - return (original.CombinedDPS - modified.CombinedDPS) / modified.CombinedDPS, defence + local modifiedDps = modified.CombinedDPS + (modified.Minion and modified.Minion.CombinedDPS or 0) + local dpsIncr = original.CombinedDPS + (original.Minion and original.Minion.CombinedDPS or 0) - modifiedDps + return dpsIncr / modifiedDps, defence end function CalcsTabClass:GetNodeCalculator() diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 24506313c6c..946c580f4ba 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -2544,10 +2544,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories end -- Get baseline stat value for percentage calculation - local baseStatValue = calcBase[powerStat.stat] or 0 - if powerStat.transform then - baseStatValue = powerStat.transform(baseStatValue) - end + local baseStatValue = data.powerStatList.GetFromOutput(calcBase, powerStat) -- Helper to format an impact value and compute percentage local function formatImpact(impact) diff --git a/src/Classes/ItemDBControl.lua b/src/Classes/ItemDBControl.lua index 3136d1a8476..77964163c43 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -190,7 +190,7 @@ end function ItemDBClass:BuildSortOrder() wipeTable(self.sortDropList) - for id,stat in pairs(data.powerStatList) do + for id, stat in ipairs(data.powerStatList) do if not stat.ignoreForItems then t_insert(self.sortDropList, { label="Sort by "..stat.label, @@ -231,10 +231,7 @@ function ItemDBClass:ListBuilder() for slotName, slot in pairs(self.itemsTab.slots) do if self.itemsTab:IsItemValidForSlot(item, slotName) and not slot.inactive and (not slot.weaponSet or slot.weaponSet == (self.itemsTab.activeItemSet.useSecondWeaponSet and 2 or 1)) then local output = calcFunc(item.base.flask and { toggleFlask = item } or item.base.tincture and { toggleTincture = item } or { repSlotName = slotName, repItem = item }, useFullDPS) - local measuredPower = output.Minion and output.Minion[self.sortMode] or output[self.sortMode] or 0 - if self.sortDetail.transform then - measuredPower = self.sortDetail.transform(measuredPower) - end + local measuredPower = data.powerStatList.GetFromOutput(output, self.sortDetail) item.measuredPower = m_max(item.measuredPower, measuredPower) end end diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index e6083ea7839..6320011f410 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -1122,7 +1122,15 @@ function ItemsTabClass:Load(xml, dbFileName) stat = child.attrib.stat, weightMult = tonumber(child.attrib.weightMult) } - t_insert(self.tradeQuery.statSortSelectionList, statSort) + for _, statEntry in ipairs(data.powerStatList) do + if statSort.stat == statEntry.stat then + -- update information which can be out of data or missing in the xml + statSort.label = statEntry.label + statSort.transform = statEntry.transform + t_insert(self.tradeQuery.statSortSelectionList, statSort) + break + end + end end end end diff --git a/src/Classes/NotableDBControl.lua b/src/Classes/NotableDBControl.lua index d08ec4080c9..e3fb85b5bb4 100644 --- a/src/Classes/NotableDBControl.lua +++ b/src/Classes/NotableDBControl.lua @@ -87,7 +87,7 @@ end function NotableDBClass:BuildSortOrder() wipeTable(self.sortDropList) - for id,stat in pairs(data.powerStatList) do + for id, stat in ipairs(data.powerStatList) do if not stat.ignoreForItems then t_insert(self.sortDropList, { label="Sort by "..stat.label, @@ -111,16 +111,8 @@ function NotableDBClass:BuildSortOrder() end function NotableDBClass:CalculatePowerStat(selection, original, modified) - if modified.Minion then - original = original.Minion - modified = modified.Minion - end - local originalValue = original[selection.stat] or 0 - local modifiedValue = modified[selection.stat] or 0 - if selection.transform then - originalValue = selection.transform(originalValue) - modifiedValue = selection.transform(modifiedValue) - end + local originalValue = data.powerStatList.GetFromOutput(original, selection) + local modifiedValue = data.powerStatList.GetFromOutput(modified, selection) return originalValue - modifiedValue end diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 9e1308bfb9e..a0afe80006b 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -553,11 +553,15 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) local controls = { } local statList = { } local sliderController = { index = 1 } - local popupHeight = 285 + local popupHeight = 500 - controls.ListControl = new("TradeStatWeightMultiplierListControl", {"TOPLEFT", nil, "TOPRIGHT"}, {-410, 45, 400, 200}, statList, sliderController) + -- account for top gap, bottom button size and gap, and a gap before buttons + local listHeight = popupHeight - 45 - 30 - 10 - for id, stat in pairs(data.powerStatList) do + controls.ListControl = new("TradeStatWeightMultiplierListControl", { "TOPLEFT", nil, "TOPRIGHT" }, + { -410, 45, 400, listHeight }, statList, sliderController) + + for _, stat in ipairs(data.powerStatList) do if not stat.ignoreForItems and stat.label ~= "Name" then t_insert(statList, { label = "0 : "..stat.label, @@ -714,7 +718,12 @@ end function TradeQueryClass:ReduceOutput(output) local smallOutput = {} for _, statTable in ipairs(self.statSortSelectionList) do - smallOutput[statTable.stat] = output[statTable.stat] + smallOutput[statTable.stat] = data.powerStatList.GetFromOutput(output, statTable) + if statTable.stat == "FullDPS" and not output.FullDPS then + smallOutput.TotalDPS = data.powerStatList.GetFromOutput(output, { stat = "TotalDPS" }) + smallOutput.TotalDotDPS = data.powerStatList.GetFromOutput(output, { stat = "TotalDotDPS" }) + smallOutput.CombinedDPS = data.powerStatList.GetFromOutput(output, { stat = "CombinedDPS" }) + end end return smallOutput end diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index eeb2fdeaab2..7a200b0833d 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -173,12 +173,13 @@ end function TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, newOutput, statWeights) local meanStatDiff = 0 + local function ratioModSums(...) local baseModSum = 0 local newModSum = 0 for _, mod in ipairs({ ... }) do - baseModSum = baseModSum + (baseOutput[mod] or 0) - newModSum = newModSum + (newOutput[mod] or 0) + baseModSum = baseModSum + data.powerStatList.GetFromOutput(baseOutput, mod, true) + newModSum = newModSum + data.powerStatList.GetFromOutput(newOutput, mod, true) end if baseModSum == math.huge then @@ -192,10 +193,17 @@ function TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, newOutput, st end end for _, statTable in ipairs(statWeights) do + local modSumRatio if statTable.stat == "FullDPS" and not (baseOutput["FullDPS"] and newOutput["FullDPS"]) then - meanStatDiff = meanStatDiff + ratioModSums("TotalDPS", "TotalDotDPS", "CombinedDPS") * statTable.weightMult + modSumRatio = ratioModSums({ stat = "TotalDPS" }, { stat = "TotalDotDPS" }, { stat = "CombinedDPS" }) + else + modSumRatio = ratioModSums(statTable) + end + -- some weights, such as damage taken from hit need to be negated as lower is better for them + if statTable.transform then + modSumRatio = statTable.transform(modSumRatio) end - meanStatDiff = meanStatDiff + ratioModSums(statTable.stat) * statTable.weightMult + meanStatDiff = meanStatDiff + modSumRatio * statTable.weightMult end return meanStatDiff end diff --git a/src/Classes/TradeStatWeightMultiplierListControl.lua b/src/Classes/TradeStatWeightMultiplierListControl.lua index 0d528470f53..f0260d89de0 100644 --- a/src/Classes/TradeStatWeightMultiplierListControl.lua +++ b/src/Classes/TradeStatWeightMultiplierListControl.lua @@ -25,7 +25,7 @@ end function TradeStatWeightMultiplierListControlClass:AddValueTooltip(tooltip, index, data) tooltip:Clear() if not self.noTooltip then - tooltip:AddLine(16, "^7Double click to modify this stats weight multiplier.") + tooltip:AddLine(16, "^7Click to modify this stats weight multiplier.") end end diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index 77bfe3ffdbf..c551b3f06c3 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -1825,54 +1825,29 @@ function TreeTabClass:FindTimelessJewel() end end - local function generateFallbackWeights(nodes, selection) + local function generateFallbackWeights(nodes, powerStat) local calcFunc, calcBase = self.build.calcsTab:GetMiscCalculator(self.build) local newList = { } - local baseOutput = calcFunc() - if baseOutput.Minion then - baseOutput = baseOutput.Minion - end - local baseValue = baseOutput[selection.stat] or 1 - if selection.transform then - baseValue = selection.transform(baseValue) - end + local basePower = data.powerStatList.GetFromOutput(calcBase, powerStat) for _, newNode in ipairs(nodes) do - local output = nil - if newNode.calcMultiple then - output = calcFunc({ addNodes = { [newNode.node[1]] = true } }) - else - output = calcFunc({ addNodes = { [newNode] = true } }) - end - if output.Minion then - output = output.Minion - end - local outputValue = output[selection.stat] or 0 - if selection.transform then - outputValue = selection.transform(outputValue) - end - outputValue = outputValue / baseValue - if outputValue ~= outputValue then - outputValue = 1 - end - t_insert(newList, { - id = newNode.id, - weight1 = (outputValue - 1) / (newNode.divisor or 1) - }) - if newNode.calcMultiple then - output = calcFunc({ addNodes = { [newNode.node[2]] = true } }) - if output.Minion then - output = output.Minion - end - outputValue = output[selection.stat] or 0 - if selection.transform then - outputValue = selection.transform(outputValue) - end - outputValue = outputValue / baseValue - if outputValue ~= outputValue then - outputValue = 1 + local powerEntry = { id = newNode.id } + -- nodes that have multiple lines are represented as a list in newNode.node + local nodeLines = newNode.node or { newNode } + for i = 1, #nodeLines do + local node = nodeLines[i] + local nodeOutput = calcFunc({ addNodes = { [node] = true } }) + local nodePower = data.powerStatList.GetFromOutput(nodeOutput, powerStat) + -- avoid infinity + if basePower == 0 then + powerEntry["weight" .. i] = 0 + else + local powerGain = (nodePower - basePower) / + -- normalize with absolute base power so that the result isn't negative + math.abs(basePower) + powerEntry["weight" .. i] = powerGain / (newNode.divisor or 1) end - newList[#newList].weight2 = (outputValue - 1) / (newNode.divisor or 1) end + t_insert(newList, powerEntry) end return newList end @@ -1991,7 +1966,7 @@ function TreeTabClass:FindTimelessJewel() end local fallbackWeightsList = { } - for id, stat in pairs(data.powerStatList) do + for _, stat in ipairs(data.powerStatList) do if not stat.ignoreForItems and stat.label ~= "Name" then t_insert(fallbackWeightsList, { label = "Sort by " .. stat.label, diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index a880019c7f8..5b25c267216 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -108,6 +108,17 @@ data = { } -- Misc data tables LoadModule("Data/Misc", data) +---@alias TransformFunc fun(in: number|string): (number|string)? +---@class StatTable +---@field stat? string stat ID +---@field label string A short description of the stat +---@field transform TransformFunc?: number|string A function to e.g. invert the value, if the stat represents something where lower is better +---@field combinedOffDef? boolean +---@field ignoreForNodes? boolean +---@field ignoreForItems? boolean +---@field reverseSort? boolean + +---@type StatTable[] data.powerStatList = { { stat=nil, label="Offence/Defence", combinedOffDef=true, ignoreForItems=true }, { stat=nil, label="Name", itemField="Name", ignoreForNodes=true, reverseSort=true, transform=function(value) return value:gsub("^The ","") end}, @@ -160,6 +171,59 @@ data.powerStatList = { { stat="SpellSuppressionChance", label="Spell Suppression Chance" }, } +---@param output any Calc output +---@param statTable StatTable Table with stats as in data.powerStatList +---@param skipTransform? boolean Whether the stat transform should be skipped. This is useful if you want to e.g. divide two less is better stats +---@return number +function data.powerStatList.GetFromOutput(output, statTable, skipTransform) + local function getEntry() + if statTable.stat == "FullDPS" then + if output[statTable.stat] ~= nil then + return output[statTable.stat] or 0 + end + -- if the user doesn't have full dps, we default to adding the player and minion dps together + return (output.CombinedDPS or 0) + (output.Minion and output.Minion.CombinedDPS or 0) + end + -- minion-only stats + local minionStat = statTable.stat:match("^Minion(.+)") + if minionStat then + return output.Minion and output.Minion[minionStat] or 0 + end + -- damage stats use a combination of player and minion dps + local isDamageStat = statTable.stat == "AverageDamage" or statTable.stat == "TotalDot" or + statTable.stat:match("DPS") + if isDamageStat then + return (output[statTable.stat] or 0) + (output.Minion and output.Minion[statTable.stat] or 0) + end + return output[statTable.stat] or 0 + end + if statTable.transform and not skipTransform then + return statTable.transform(getEntry()) + end + return getEntry() +end + +-- these stats don't exist on minions or generally don't exist on both player and minion +local minionNonApplicableStats = { + AverageDamage = true, + TotalDot = true, + Str = true, + Dex = true, + Int = true, + Spirit = true, + EffectiveLootRarityMod = true, +} +for i = 1, #data.powerStatList do + local statEntry = data.powerStatList[i] + if (not statEntry.stat) or statEntry.stat:match("DPS") or minionNonApplicableStats[statEntry.stat] then + goto statContinue + end + local minionStat = copyTable(statEntry) + minionStat.stat = "Minion" .. minionStat.stat + minionStat.label = "Minion " .. minionStat.label + t_insert(data.powerStatList, minionStat) + ::statContinue:: +end data.misc = { -- magic numbers ServerTickTime = 0.033, ServerTickRate = 1 / 0.033,