diff --git a/spec/System/TestTradeQueryCurrency_spec.lua b/spec/System/TestTradeQueryCurrency_spec.lua index 6052a9759f..2758a84363 100644 --- a/spec/System/TestTradeQueryCurrency_spec.lua +++ b/spec/System/TestTradeQueryCurrency_spec.lua @@ -23,24 +23,6 @@ describe("TradeQuery Currency Conversion", function() 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(250, result.AverageDamage) - assert.is_nil(result.Life) - end) - end) - describe("PriceBuilderProcessPoENinjaResponse", function() -- Pass: Processes without error, restoring map while adding a notice -- Fail: Corrupts map or crashes, indicating fragile API response handling, breaking future conversions diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index fa25d5eb35..850fc53371 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -35,10 +35,10 @@ describe("TradeQueryGenerator", function() assert.are.equal(result, 100) end) - it("uses minion output for non-FullDPS stats when minion output is available", function() - local baseOutput = { AverageDamage = 10, Minion = { AverageDamage = 100 } } - local newOutput = { AverageDamage = 10, Minion = { AverageDamage = 250 } } - local statWeights = { { stat = "AverageDamage", weightMult = 1 } } + 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) @@ -46,7 +46,20 @@ describe("TradeQueryGenerator", function() assert.are.equal(result, 2.5) end) - it("uses player output for FullDPS even when minion output is available", function() + 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 = (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 } } @@ -57,6 +70,16 @@ describe("TradeQueryGenerator", function() 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 } } diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua new file mode 100644 index 0000000000..0a6227300b --- /dev/null +++ b/spec/System/TestTradeQuery_spec.lua @@ -0,0 +1,25 @@ +describe("TradeQuery", function () + local mock_tradeQuery + + before_each(function() + mock_tradeQuery = new("TradeQuery", { itemsTab = {} }) + 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) + end) +end) \ No newline at end of file diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index aabaec1bd8..96ec45e82c 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -672,16 +672,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 @@ -692,10 +684,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.CombinedDPS or 0) + local dpsIncr = original.CombinedDPS + (original.Minion.CombinedDPS or 0) - modifiedDps + return (original.CombinedDPS - modified.CombinedDPS) / modifiedDps, defence end function CalcsTabClass:GetNodeCalculator() diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 7656c735b6..5ee34f4ecc 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -2652,10 +2652,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 cbe015240f..0e3c8231e9 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -202,7 +202,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, @@ -243,10 +243,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.charm and { toggleCharm = 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 = item.measuredPower and m_max(item.measuredPower, measuredPower) or measuredPower end end diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index d3ffb571c6..e58fe99176 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -1144,7 +1144,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 c0a9277aa9..9d66ddcc98 100644 --- a/src/Classes/NotableDBControl.lua +++ b/src/Classes/NotableDBControl.lua @@ -135,7 +135,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, @@ -159,16 +159,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 b1d3831965..5667b621fb 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -623,11 +623,16 @@ 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) + local listYOffset = 45 + -- 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, @@ -774,7 +779,7 @@ end function TradeQueryClass:ReduceOutput(output) local smallOutput = {} for _, statTable in ipairs(self.statSortSelectionList) do - smallOutput[statTable.stat] = output.Minion and output.Minion[statTable.stat] or output[statTable.stat] + smallOutput[statTable.stat] = data.powerStatList.GetFromOutput(output, statTable) end return smallOutput end diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 41314a37b5..f11ce4cd0b 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -157,26 +157,13 @@ end function TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, newOutput, statWeights) local meanStatDiff = 0 - local function getOutputStatValue(output, stat) - if stat == "FullDPS" then - if output[stat] ~= nil then - return output[stat] - end - if output.Minion and output.Minion.CombinedDPS ~= nil then - return output.Minion.CombinedDPS - end - end - if output.Minion and output.Minion[stat] ~= nil then - return output.Minion[stat] - end - return output[stat] or 0 - end + local function ratioModSums(...) local baseModSum = 0 local newModSum = 0 for _, mod in ipairs({ ... }) do - baseModSum = baseModSum + getOutputStatValue(baseOutput, mod) - newModSum = newModSum + getOutputStatValue(newOutput, mod) + baseModSum = baseModSum + data.powerStatList.GetFromOutput(baseOutput, mod) + newModSum = newModSum + data.powerStatList.GetFromOutput(newOutput, mod) end if baseModSum == math.huge then @@ -192,9 +179,9 @@ function TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, newOutput, st for _, statTable in ipairs(statWeights) do local modSumRatio if statTable.stat == "FullDPS" and not (baseOutput["FullDPS"] and newOutput["FullDPS"]) then - modSumRatio = ratioModSums("TotalDPS", "TotalDotDPS", "CombinedDPS") + modSumRatio = ratioModSums({ stat = "TotalDPS" }, { stat = "TotalDotDPS" }, { stat = "CombinedDPS" }) else - modSumRatio = ratioModSums(statTable.stat) + 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 diff --git a/src/Classes/TradeStatWeightMultiplierListControl.lua b/src/Classes/TradeStatWeightMultiplierListControl.lua index 0d528470f5..f0260d89de 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 3de5954cb6..0ff74fc38e 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -1850,7 +1850,7 @@ function TreeTabClass:FindTimelessJewel() controls.fallbackWeightsLabel = new("LabelControl", {"TOPRIGHT", nil, "TOPLEFT"}, {405, 225, 0, 16}, "^7Fallback Weight Mode:") 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 8fc98820d9..e854fc9992 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -114,6 +114,16 @@ data = { } -- Misc data tables LoadModule("Data/Misc", data) +---@class StatTable +---@field stat? string stat ID +---@field label string A short description of the stat +---@field transform fun(in: number|string): 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}, @@ -168,6 +178,58 @@ data.powerStatList = { { stat="EffectiveLootRarityMod", label="Rarity of Items found" }, } +---@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) + 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,