diff --git a/spec/System/TestPowerReport_spec.lua b/spec/System/TestPowerReport_spec.lua index a98126e575..123f058dfe 100644 --- a/spec/System/TestPowerReport_spec.lua +++ b/spec/System/TestPowerReport_spec.lua @@ -36,3 +36,196 @@ describe("PowerReportListControl", function() assert.are.equal("allocated", list[1].name) end) end) + +local function findPowerStat(stat) + for _, powerStat in ipairs(data.powerStatList) do + if powerStat.stat == stat then + return powerStat + end + end +end + +describe("Power report calculation requirements", function() + before_each(function() + newBuild() + end) + + it("marks only the metrics that require eHP or Full DPS", function() + local expectedEHPStats = { + TotalEHP = true, + SecondMinimalMaximumHitTaken = true, + PhysicalTakenHit = true, + LightningTakenHit = true, + ColdTakenHit = true, + FireTakenHit = true, + ChaosTakenHit = true, + } + local eHPStatCount = 0 + local fullDPSStatCount = 0 + for _, powerStat in ipairs(data.powerStatList) do + if powerStat.requiresEHP then + eHPStatCount = eHPStatCount + 1 + assert.is_true(expectedEHPStats[powerStat.stat:gsub("^Minion", "")]) + end + if powerStat.requiresFullDPS then + fullDPSStatCount = fullDPSStatCount + 1 + assert.are.equal("FullDPS", powerStat.stat) + end + end + + assert.are.equal(14, eHPStatCount) + assert.are.equal(1, fullDPSStatCount) + assert.is_true(findPowerStat("MinionTotalEHP").requiresEHP) + assert.is_nil(findPowerStat("MeleeAvoidChance").requiresEHP) + assert.is_nil(findPowerStat("SpellAvoidChance").requiresEHP) + assert.is_nil(findPowerStat("ProjectileAvoidChance").requiresEHP) + end) +end) + +describe("Power report calculator options", function() + local calcs + local originalCalcFullDPS + local originalPerform + + before_each(function() + newBuild() + calcs = build.calcsTab.calcs + originalCalcFullDPS = calcs.calcFullDPS + originalPerform = calcs.perform + end) + + after_each(function() + calcs.calcFullDPS = originalCalcFullDPS + calcs.perform = originalPerform + end) + + local function makeCalculator() + local state = { + fullDPSCalls = 0, + performSkipEHP = { }, + } + calcs.calcFullDPS = function() + state.fullDPSCalls = state.fullDPSCalls + 1 + return { skills = { { } }, combinedDPS = 1, TotalDotDPS = 0 } + end + calcs.perform = function(env, skipEHP) + table.insert(state.performSkipEHP, skipEHP == nil and "nil" or skipEHP) + return originalPerform(env, skipEHP) + end + + local calcFunc = calcs.getMiscCalculator(build) + state.fullDPSCalls = 0 + state.performSkipEHP = { } + return calcFunc, state + end + + it("honors explicit stage skips for each report type", function() + build.viewMode = "TREE" + local calcFunc, state = makeCalculator() + + calcFunc({ }, nil, { skipEHP = true, skipFullDPS = true }) + assert.are.same({ true }, state.performSkipEHP) + assert.are.equal(0, state.fullDPSCalls) + + state.performSkipEHP = { } + calcFunc({ }, true, { skipEHP = true, skipFullDPS = false }) + assert.are.same({ true }, state.performSkipEHP) + assert.are.equal(1, state.fullDPSCalls) + + state.performSkipEHP = { } + calcFunc({ }, false, { skipEHP = false, skipFullDPS = true }) + assert.are.same({ false }, state.performSkipEHP) + assert.are.equal(1, state.fullDPSCalls) + end) + + it("preserves legacy Tree calculations when options are omitted", function() + build.viewMode = "TREE" + local calcFunc, state = makeCalculator() + + calcFunc({ }, false) + assert.are.same({ "nil" }, state.performSkipEHP) + assert.are.equal(1, state.fullDPSCalls) + end) + + it("preserves representative report values", function() + build.viewMode = "TREE" + local calcFunc, calcBase = calcs.getMiscCalculator(build) + local testNode + for nodeId, node in pairs(build.spec.nodes) do + if not node.alloc and node.type ~= "Mastery" and node.modKey ~= "" and not build.calcsTab.mainEnv.grantedPassives[nodeId] then + testNode = node + break + end + end + assert(testNode) + local override = { addNodes = { [testNode] = true } } + + for _, stat in ipairs({ "Life", "TotalDPS", "FullDPS", "TotalEHP" }) do + local powerStat = findPowerStat(stat) + local useFullDPS = powerStat.requiresFullDPS or false + local legacyOutput = calcFunc(override, useFullDPS) + local optimizedOutput = calcFunc(override, useFullDPS, { + skipEHP = not powerStat.requiresEHP, + skipFullDPS = not useFullDPS, + }) + assert.are.near( + data.powerStatList.GetFromOutput(legacyOutput, powerStat), + data.powerStatList.GetFromOutput(optimizedOutput, powerStat), + 10 ^ -9 + ) + end + + local legacyOutput = calcFunc(override, false) + local optimizedOutput = calcFunc(override, false, { skipEHP = true, skipFullDPS = true }) + local legacyOffence, legacyDefence = build.calcsTab:CalculateCombinedOffDefStat(legacyOutput, calcBase) + local optimizedOffence, optimizedDefence = build.calcsTab:CalculateCombinedOffDefStat(optimizedOutput, calcBase) + assert.are.near(legacyOffence, optimizedOffence, 10 ^ -9) + assert.are.near(legacyDefence, optimizedDefence, 10 ^ -9) + end) +end) + +describe("PowerBuilder calculation options", function() + before_each(function() + newBuild() + end) + + it("uses one selected-metric option set for all candidate calculations", function() + local output = { + Life = 101, + TotalEHP = 101, + FullDPS = 101, + CombinedDPS = 101, + LifeUnreserved = 101, + Armour = 101, + EnergyShield = 101, + Evasion = 101, + LifeRegenRecovery = 101, + EnergyShieldRegenRecovery = 101, + Minion = { TotalEHP = 101, CombinedDPS = 101 }, + } + local baseOutput = output + + local function runPowerBuilder(powerStat, expectedOptions) + local callCount = 0 + build.calcsTab.powerStat = powerStat + build.calcsTab.powerMax = nil + build.calcsTab.nodePowerMaxDepth = 1 + build.calcsTab.miscCalculator = { function(override, useFullDPS, options) + callCount = callCount + 1 + assert.are.same(expectedOptions, options) + assert.are.equal(not expectedOptions.skipFullDPS, useFullDPS) + return output + end, baseOutput } + + build.calcsTab:PowerBuilder() + assert.is_true(callCount > 0) + end + + runPowerBuilder(findPowerStat("Life"), { skipEHP = true, skipFullDPS = true }) + runPowerBuilder(findPowerStat("FullDPS"), { skipEHP = true, skipFullDPS = false }) + runPowerBuilder(findPowerStat("TotalEHP"), { skipEHP = false, skipFullDPS = true }) + runPowerBuilder(findPowerStat("MinionTotalEHP"), { skipEHP = false, skipFullDPS = true }) + runPowerBuilder(findPowerStat("MeleeAvoidChance"), { skipEHP = true, skipFullDPS = true }) + runPowerBuilder(data.powerStatList[1], { skipEHP = true, skipFullDPS = true }) + end) +end) diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index 7d79e865d9..dba7d88c91 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -495,8 +495,15 @@ end -- Estimate the offensive and defensive power of all unallocated nodes function CalcsTabClass:PowerBuilder() -- local timer_start = GetTime() - local useFullDPS = self.powerStat and self.powerStat.stat == "FullDPS" + local useFullDPS = self.powerStat and self.powerStat.requiresFullDPS or false + local calcOptions = { + skipEHP = not (self.powerStat and self.powerStat.requiresEHP), + skipFullDPS = not useFullDPS, + } local calcFunc, calcBase = self:GetMiscCalculator() + local function calcPower(override) + return calcFunc(override, useFullDPS, calcOptions) + end local cache = { } local distanceMap = { } local distanceList = { } @@ -541,7 +548,7 @@ function CalcsTabClass:PowerBuilder() newPowerMax.singleStat = m_max(newPowerMax.singleStat, power.singleStat) power.pathPower = power.singleStat if distance > 1 then - power.pathPower = self:CalculatePowerStat(self.powerStat, calcFunc({ addNodes = buildPathNodes() }, useFullDPS), calcBase) + power.pathPower = self:CalculatePowerStat(self.powerStat, calcPower({ addNodes = buildPathNodes() }), calcBase) end end elseif not self.powerStat or not self.powerStat.ignoreForNodes then @@ -611,7 +618,7 @@ function CalcsTabClass:PowerBuilder() for nodeId, node in pairs(nodes) do if not node.alloc and node.modKey ~= "" and not self.mainEnv.grantedPassives[nodeId] then if not cache[node.modKey] then - cache[node.modKey] = calcFunc({ addNodes = { [node] = true } }, useFullDPS) + cache[node.modKey] = calcPower({ addNodes = { [node] = true } }) end local output = cache[node.modKey] calculateAddNodePower(node.power, distance, node, output, function() @@ -623,7 +630,7 @@ function CalcsTabClass:PowerBuilder() end) elseif node.alloc and node.modKey ~= "" and not self.mainEnv.grantedPassives[nodeId] then if not cache[node.modKey.."_remove"] then - cache[node.modKey.."_remove"] = calcFunc({ removeNodes = { [node] = true } }, useFullDPS) + cache[node.modKey.."_remove"] = calcPower({ removeNodes = { [node] = true } }) end local output = cache[node.modKey.."_remove"] if self.powerStat and self.powerStat.stat and not self.powerStat.ignoreForNodes then @@ -635,7 +642,7 @@ function CalcsTabClass:PowerBuilder() pathNodes[node] = true end if #node.depends > 1 then - node.power.pathPower = self:CalculatePowerStat(self.powerStat, calcFunc({ removeNodes = pathNodes }, useFullDPS), calcBase) + node.power.pathPower = self:CalculatePowerStat(self.powerStat, calcPower({ removeNodes = pathNodes }), calcBase) end end end @@ -670,7 +677,7 @@ function CalcsTabClass:PowerBuilder() local effectNode = buildMasteryEffectNode(node, effect) if effectNode.modKey ~= "" then if not cache[effectNode.modKey] then - cache[effectNode.modKey] = calcFunc({ addNodes = { [effectNode] = true } }, useFullDPS) + cache[effectNode.modKey] = calcPower({ addNodes = { [effectNode] = true } }) end local output = cache[effectNode.modKey] node.power.masteryEffects[effect.id] = { } @@ -718,7 +725,7 @@ function CalcsTabClass:PowerBuilder() wipeTable(node.power) if not node.alloc and node.modKey ~= "" and not self.mainEnv.grantedPassives[node.id] then if not cache[node.modKey] then - cache[node.modKey] = calcFunc({ addNodes = { [node] = true } }, useFullDPS) + cache[node.modKey] = calcPower({ addNodes = { [node] = true } }) end local output = cache[node.modKey] if self.powerStat and self.powerStat.stat and not self.powerStat.ignoreForNodes then diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index dc12041374..403d11aab4 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -82,9 +82,13 @@ end ---@field conditions string[]? ---@field extraJewelFuncs ModList? +---@class MiscCalculatorOptions +---@field skipEHP boolean? Skip effective hit pool and maximum hit estimations. +---@field skipFullDPS boolean? Skip the Full DPS roll-up. + -- Get calculator for other changes (adding/removing nodes, items, gems, etc) ---@param build Build ----@return fun(override?: CalcOverride, useFullDPS?: boolean): Output calcFunc +---@return fun(override?: CalcOverride, useFullDPS?: boolean, options?: MiscCalculatorOptions): Output calcFunc ---@return Output output function calcs.getMiscCalculator(build) -- Run base calculation pass @@ -97,10 +101,11 @@ function calcs.getMiscCalculator(build) env.player.output.FullDPS = fullDPS.combinedDPS env.player.output.FullDotDPS = fullDPS.TotalDotDPS end - return function(override, useFullDPS) + return function(override, useFullDPS, options) local env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, "CALCULATOR", override) - calcs.perform(env) - if (useFullDPS ~= false or build.viewMode == "TREE") and usedFullDPS then + calcs.perform(env, options and options.skipEHP) + local calculateFullDPS = not (options and options.skipFullDPS) and (useFullDPS ~= false or build.viewMode == "TREE") + if calculateFullDPS and usedFullDPS then -- prevent upcoming calculation from using Cached Data and thus forcing it to re-calculate new FullDPS roll-up -- without this, FullDPS increase/decrease when for node/item/gem comparison would be all 0 as it would be comparing -- A with A (due to cache reuse) instead of A with B diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index 26afb5f4df..5ae791387d 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -124,12 +124,14 @@ end ---@field ignoreForItems? boolean ---@field reverseSort? boolean ---@field itemField string? +---@field requiresEHP? boolean Whether this stat requires effective health and maximum hit estimations +---@field requiresFullDPS? boolean Whether this stat requires the Full DPS roll-up ---@type PowerStat[] 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}, - { stat="FullDPS", label="Full DPS" }, + { stat="FullDPS", label="Full DPS", requiresFullDPS=true }, { stat="CombinedDPS", label="Combined DPS" }, { stat="TotalDPS", label="Hit DPS" }, { stat="WithImpaleDPS", label="Impale + Hit DPS" }, @@ -159,13 +161,13 @@ data.powerStatList = { { stat="MeleeAvoidChance", label="Melee avoid chance" }, { stat="SpellAvoidChance", label="Spell avoid chance" }, { stat="ProjectileAvoidChance", label="Projectile avoid chance" }, - { stat="TotalEHP", label="Effective Hit Pool" }, - { stat="SecondMinimalMaximumHitTaken", label="Eff. Maximum Hit Taken" }, - { stat="PhysicalTakenHit", label="Taken Phys dmg", transform=function(value) return -value end }, - { stat="LightningTakenHit", label="Taken Lightning dmg", transform=function(value) return -value end }, - { stat="ColdTakenHit", label="Taken Cold dmg", transform=function(value) return -value end }, - { stat="FireTakenHit", label="Taken Fire dmg", transform=function(value) return -value end }, - { stat="ChaosTakenHit", label="Taken Chaos dmg", transform=function(value) return -value end }, + { stat="TotalEHP", label="Effective Hit Pool", requiresEHP=true }, + { stat="SecondMinimalMaximumHitTaken", label="Eff. Maximum Hit Taken", requiresEHP=true }, + { stat="PhysicalTakenHit", label="Taken Phys dmg", transform=function(value) return -value end, requiresEHP=true }, + { stat="LightningTakenHit", label="Taken Lightning dmg", transform=function(value) return -value end, requiresEHP=true }, + { stat="ColdTakenHit", label="Taken Cold dmg", transform=function(value) return -value end, requiresEHP=true }, + { stat="FireTakenHit", label="Taken Fire dmg", transform=function(value) return -value end, requiresEHP=true }, + { stat="ChaosTakenHit", label="Taken Chaos dmg", transform=function(value) return -value end, requiresEHP=true }, { stat="CritChance", label="Crit Chance" }, { stat="CritMultiplier", label="Crit Multiplier" }, { stat="BleedChance", label="Bleed Chance" },