diff --git a/spec/System/TestItemDBControl_spec.lua b/spec/System/TestItemDBControl_spec.lua new file mode 100644 index 00000000000..b6e4e702187 --- /dev/null +++ b/spec/System/TestItemDBControl_spec.lua @@ -0,0 +1,54 @@ +describe("ItemDBControl", function() + it("sorts lower-is-better stats below zero", function() + local function makeItem(name) + return { + name = name, + base = {}, + enchantModLines = {}, + implicitModLines = {}, + explicitModLines = {}, + baseModList = {}, + } + end + local betterItem = makeItem("Better Item") + local worseItem = makeItem("Worse Item") + local invalidItem = makeItem("Invalid Item") + local takenDamage = { + [betterItem] = 80, + [worseItem] = 120, + } + local itemsTab = { + activeItemSet = { useSecondWeaponSet = false }, + slots = { ["Body Armour"] = {} }, + build = { + calcsTab = { + GetMiscCalculator = function() + return function(args) + return { PhysicalTakenHit = takenDamage[args.repItem] } + end + end, + }, + }, + IsItemValidForSlot = function(_, item) + return item ~= invalidItem + end, + } + local control = new("ItemDBControl", nil, { 0, 0, 100, 100 }, itemsTab, { + list = { invalidItem, betterItem, worseItem }, + }, "RARE") + control.sortDetail = { + stat = "PhysicalTakenHit", + transform = function(value) return -value end, + } + control.sortOrder = { control.sortControl.STAT, control.sortControl.NAME } + + control:ListBuilder() + + assert.are.equal(betterItem, control.list[1]) + assert.are.equal(worseItem, control.list[2]) + assert.are.equal(invalidItem, control.list[3]) + assert.are.equal(-80, betterItem.measuredPower) + assert.are.equal(-120, worseItem.measuredPower) + assert.are.equal(-math.huge, invalidItem.measuredPower) + end) +end) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index befb96a6570..e4d93d9aafe 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -15,6 +15,17 @@ describe("TradeQueryGenerator", function() end) describe("WeightedRatioOutputs", function() + local maxStatIncrease + + before_each(function() + maxStatIncrease = data.misc.maxStatIncrease + data.misc.maxStatIncrease = 1000 + end) + + after_each(function() + data.misc.maxStatIncrease = maxStatIncrease + end) + -- Pass: Returns 0, avoiding math errors -- Fail: Returns NaN/inf or crashes, indicating unhandled infinite values, causing evaluation failures in infinite-scaling builds it("handles infinite base", function() @@ -31,10 +42,63 @@ describe("TradeQueryGenerator", function() local baseOutput = { TotalDPS = 0 } local newOutput = { TotalDPS = 100 } local statWeights = { { stat = "TotalDPS", weightMult = 1 } } - data.misc.maxStatIncrease = 1000 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 } } + 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 } } + 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 } } + 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 } } + 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 } } + 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 } } + 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..68eaf5d32c7 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,57 @@ describe("TradeQuery", function() assert.are.equal(0, #tooltip.lines) end) end) + describe("ReduceOutput", function() + it("preserves lower-is-better values for weighted result comparison", function() + local weights = { + { stat = "PhysicalTakenHit", weightMult = 1, transform = function(value) return -value end }, + } + mock_tradeQuery.statSortSelectionList = weights + + local baseOutput = { PhysicalTakenHit = 100 } + local betterOutput = mock_tradeQuery:ReduceOutput({ PhysicalTakenHit = 80 }) + local worseOutput = mock_tradeQuery:ReduceOutput({ PhysicalTakenHit = 120 }) + local betterWeight = mock_queryGen.WeightedRatioOutputs(baseOutput, betterOutput, weights) + local worseWeight = mock_queryGen.WeightedRatioOutputs(baseOutput, worseOutput, weights) + + assert.are.equal(80, betterOutput.PhysicalTakenHit) + assert.is_true(betterWeight > worseWeight) + end) + + 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..8cd0eab1c26 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, @@ -227,14 +227,11 @@ function ItemDBClass:ListBuilder() local start = GetTime() local calcFunc, calcBase = self.itemsTab.build.calcsTab:GetMiscCalculator(self.build) for itemIndex, item in ipairs(list) do - item.measuredPower = 0 + item.measuredPower = -math.huge 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 @@ -363,4 +360,4 @@ function ItemDBClass:OnHoverKeyUp(key) itemLib.wiki.openItem(item) end end -end \ No newline at end of file +end diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index e6083ea7839..b122e921d38 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -64,32 +64,14 @@ end local function buildModSortList() local sortList = { { label = "Default", stat = nil } } - local sortTransforms = { } + local sortStats = { } for _, entry in ipairs(data.powerStatList) do if entry.stat and not entry.ignoreForNodes then t_insert(sortList, { label = entry.label, stat = entry.stat }) - sortTransforms[entry.stat] = entry.transform + sortStats[entry.stat] = entry end end - return sortList, sortTransforms -end - -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 - if output[stat] ~= nil then - return output[stat] - end - return 0 + return sortList, sortStats end local ItemsTabClass = newClass("ItemsTab", "UndoHandler", "ControlHost", "Control", function(self, build) @@ -1122,7 +1104,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 @@ -2309,7 +2299,7 @@ function ItemsTabClass:EnchantDisplayItem(enchantSlot) end end local enchantmentList = { } - local sortList, sortTransforms = buildModSortList() + local sortList, sortStats = buildModSortList() local function setDefaultSortOrder() for index, entry in ipairs(enchantmentList) do entry.defaultSortOrder = index @@ -2377,10 +2367,7 @@ function ItemsTabClass:EnchantDisplayItem(enchantSlot) end item:BuildAndParseRaw() local output = calcFunc({ repSlotName = slotName, repItem = item }, useFullDPS) - local value = getOutputStatValue(output, stat) - if sortTransforms[stat] then - value = sortTransforms[stat](value) - end + local value = data.powerStatList.GetFromOutput(output, sortStats[stat]) entry.sortValues[stat] = value return value end @@ -2627,7 +2614,7 @@ function ItemsTabClass:CorruptDisplayItem(modType) local controls = { } local implicitList = { } local sourceList = { "Corrupted", "Scourge" } - local sortList, sortTransforms = buildModSortList() + local sortList, sortStats = buildModSortList() local function buildImplicitList(modType) if implicitList[modType] then return @@ -2686,10 +2673,7 @@ function ItemsTabClass:CorruptDisplayItem(modType) end item:BuildAndParseRaw() local output = calcFunc({ repSlotName = slotName, repItem = item }, useFullDPS) - local value = getOutputStatValue(output, stat) - if sortTransforms[stat] then - value = sortTransforms[stat](value) - end + local value = data.powerStatList.GetFromOutput(output, sortStats[stat]) entry.sortValues[stat] = value return value end @@ -2908,7 +2892,7 @@ function ItemsTabClass:AddCustomModifierToDisplayItem() local controls = { } local sourceList = { } local modList = { } - local sortList, sortTransforms = buildModSortList() + local sortList, sortStats = buildModSortList() local function setDefaultSortOrder() for index, listMod in ipairs(modList) do listMod.defaultSortOrder = index @@ -2928,10 +2912,7 @@ function ItemsTabClass:AddCustomModifierToDisplayItem() end item:BuildAndParseRaw() local output = calcFunc({ repSlotName = slotName, repItem = item }, useFullDPS) - local value = getOutputStatValue(output, stat) - if sortTransforms[stat] then - value = sortTransforms[stat](value) - end + local value = data.powerStatList.GetFromOutput(output, sortStats[stat]) listMod.sortValues[stat] = value return value end @@ -3328,7 +3309,7 @@ function ItemsTabClass:AddImplicitToDisplayItem() local sourceList = { } local modList = { } local modGroups = {} - local sortList, sortTransforms = buildModSortList() + local sortList, sortStats = buildModSortList() local function setDefaultSortOrder() for groupIndex, group in ipairs(modGroups) do group.defaultSortOrder = groupIndex @@ -3516,10 +3497,7 @@ function ItemsTabClass:AddImplicitToDisplayItem() applyCandidateMod(item, listMod) item:BuildAndParseRaw() local output = calcFunc({ repSlotName = slotName, repItem = item }, useFullDPS) - local value = getOutputStatValue(output, stat) - if sortTransforms[stat] then - value = sortTransforms[stat](value) - end + local value = data.powerStatList.GetFromOutput(output, sortStats[stat]) listMod.sortValues[stat] = value return value 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..8a024580dc8 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, true) + 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,