diff --git a/spec/System/TestNodePower_spec.lua b/spec/System/TestNodePower_spec.lua new file mode 100644 index 0000000000..8aa59a9b1b --- /dev/null +++ b/spec/System/TestNodePower_spec.lua @@ -0,0 +1,112 @@ +describe("TestNodePower", function() + before_each(function() + newBuild() + end) + + -- Start as the given class with Spark in Full DPS, so nodes near the class start matter + local function setUp(className) + build.spec:SelectClass(build.spec.tree.classNameMap[className]) + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + runCallback("OnFrame") + local group = build.skillsTab.socketGroupList[1] + group.mainActiveSkill = 1 + group.includeInFullDPS = true + build.mainSocketGroup = 1 + -- The tree view forces Full DPS in the slow path, which node power must not depend on + build.viewMode = "TREE" + build.buildFlag = true + runCallback("OnFrame") + end + + local function findStat(stat) + for _, powerStat in ipairs(data.powerStatList) do + if powerStat.stat == stat then + return powerStat + end + end + end + + -- Run the node power builder to completion for the nodes closest to the allocated tree + local function buildPower(powerStat) + local calcsTab = build.calcsTab + calcsTab.powerStat = powerStat + calcsTab.nodePowerMaxDepth = 5 + calcsTab.powerBuildFlag = true + repeat + calcsTab:BuildPower() + until not calcsTab.powerBuilder + end + + -- Node power must equal the stat difference from a full, unaccelerated calculation + local function assertMatchesSlowPath(powerStat) + buildPower(powerStat) + local calcsTab = build.calcsTab + local calcFunc, calcBase = calcsTab:GetMiscCalculator() + local checked, nonZero = 0, 0 + for _, node in pairs(build.spec.nodes) do + if not node.alloc and node.power.singleStat and node.power.distance <= 5 then + local expected = calcsTab:CalculatePowerStat(powerStat, calcFunc({ addNodes = { [node] = true } }), calcBase) + local scale = math.max(math.abs(expected), 1) + assert.is_true(math.abs(expected - node.power.singleStat) <= scale * 1e-9, + powerStat.stat .. " power of " .. node.dn .. ": expected " .. expected .. ", got " .. node.power.singleStat) + checked = checked + 1 + if expected ~= 0 then + nonZero = nonZero + 1 + end + end + end + assert.is_true(checked > 0, "no nodes checked") + return nonZero + end + + it("Full DPS power reflects the node, not cached skill results", function() + setUp("Sorceress") + assert.is_true(assertMatchesSlowPath(findStat("FullDPS")) > 0, "expected some node to change Full DPS") + end) + + it("does not recalculate Full DPS skills that cannot deal damage", function() + setUp("Sorceress") + build.skillsTab:PasteSocketGroup("Wind Dancer 20/0 1") + runCallback("OnFrame") + build.skillsTab.socketGroupList[2].includeInFullDPS = true + build.buildFlag = true + runCallback("OnFrame") + local calcs = build.calcsTab.calcs + local realPerform = calcs.perform + local performs = { } + calcs.perform = function(env, ...) + realPerform(env, ...) + local name = env.player.mainSkill.activeEffect.grantedEffect.name + performs[name] = (performs[name] or 0) + 1 + end + buildPower(findStat("FullDPS")) + calcs.perform = realPerform + assert.is_true((performs["Spark"] or 0) > 0, "Spark should be recalculated") + assert.is_nil(performs["Wind Dancer"], "Wind Dancer cannot deal damage and should be served from the base pass") + assertMatchesSlowPath(findStat("FullDPS")) + end) + + it("Life power matches the full calculation", function() + setUp("Warrior") + assert.is_true(assertMatchesSlowPath(findStat("Life")) > 0, "expected some node to change Life") + end) + + it("EHP power matches the full calculation", function() + setUp("Warrior") + assert.is_true(assertMatchesSlowPath(findStat("TotalEHP")) > 0, "expected some node to change EHP") + end) + + it("does not calculate Full DPS for other stats", function() + setUp("Warrior") + local calcs = build.calcsTab.calcs + local realCalcFullDPS = calcs.calcFullDPS + local fullDPSCalls = 0 + calcs.calcFullDPS = function(...) + fullDPSCalls = fullDPSCalls + 1 + return realCalcFullDPS(...) + end + buildPower(findStat("Life")) + calcs.calcFullDPS = realCalcFullDPS + assert.are.equal(0, fullDPSCalls) + end) +end) diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index 2c39f156cb..cb9e17ca54 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -551,6 +551,8 @@ end function CalcsTabClass:PowerBuilder() -- local timer_start = GetTime() local useFullDPS = self.powerStat and self.powerStat.stat == "FullDPS" + -- Only the selected power stat is read from each output, so skip the passes it does not need + local fastCalcOptions = { fullDPSOnly = useFullDPS, skipEHP = not (self.powerStat and self.powerStat.needsEHP) } local calcFunc, calcBase = self:GetMiscCalculator() local cache = { } local distanceMap = { } @@ -569,9 +571,20 @@ function CalcsTabClass:PowerBuilder() coroutine.yield() end + local singleStat = self.powerStat and self.powerStat.stat and not self.powerStat.ignoreForNodes local start = GetTime() local nodeIndex = 0 local total = 0 + local function step() + nodeIndex = nodeIndex + 1 + if coroutine.running() and GetTime() - start > 100 then + if self.build.powerBuilderProgressCallback then + self.build.powerBuilderProgressCallback(m_floor(nodeIndex/total*100)) + end + coroutine.yield() + start = GetTime() + end + end for nodeId, node in pairs(self.build.spec.nodes) do wipeTable(node.power) @@ -598,6 +611,10 @@ function CalcsTabClass:PowerBuilder() node.power.distance = dist if (not self.nodePowerMaxDepth) or dist <= self.nodePowerMaxDepth then total = total + 1 + -- Path power is calculated in a second pass, so count it towards progress too + if singleStat and not node.ascendancyName and (node.alloc and #(node.depends or { }) > 1 or not node.alloc and node.path and dist > 1) then + total = total + 1 + end end end end @@ -614,6 +631,9 @@ function CalcsTabClass:PowerBuilder() end end + -- Path power needs a calculation per path, so it is deferred until every node's own + -- power (all the heat map shows) is known + local pathJobs = { } for _, data in ipairs(distanceList) do local distance, nodes = data[1], data[2] if self.nodePowerMaxDepth and self.nodePowerMaxDepth < distance then @@ -622,7 +642,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] = calcFunc({ addNodes = { [node] = true } }, useFullDPS, fastCalcOptions) end local output = cache[node.modKey] if self.powerStat and self.powerStat.stat and not self.powerStat.ignoreForNodes then @@ -635,7 +655,7 @@ function CalcsTabClass:PowerBuilder() pathNodes[node] = true end if distance > 1 then - node.power.pathPower = self:CalculatePowerStat(self.powerStat, calcFunc({ addNodes = pathNodes }, useFullDPS), calcBase) + t_insert(pathJobs, { node = node, override = { addNodes = pathNodes } }) end end elseif not self.powerStat or not self.powerStat.ignoreForNodes then @@ -650,7 +670,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"] = calcFunc({ removeNodes = { [node] = true } }, useFullDPS, fastCalcOptions) end local output = cache[node.modKey.."_remove"] if self.powerStat and self.powerStat.stat and not self.powerStat.ignoreForNodes then @@ -662,19 +682,12 @@ function CalcsTabClass:PowerBuilder() pathNodes[node] = true end if #node.depends > 1 then - node.power.pathPower = self:CalculatePowerStat(self.powerStat, calcFunc({ removeNodes = pathNodes }, useFullDPS), calcBase) + t_insert(pathJobs, { node = node, override = { removeNodes = pathNodes } }) end end end end - nodeIndex = nodeIndex + 1 - if coroutine.running() and GetTime() - start > 100 then - if self.build.powerBuilderProgressCallback then - self.build.powerBuilderProgressCallback(m_floor(nodeIndex/total*100)) - end - coroutine.yield() - start = GetTime() - end + step() end end @@ -687,23 +700,22 @@ function CalcsTabClass:PowerBuilder() wipeTable(node.power) 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] = calcFunc({ addNodes = { [node] = true } }, useFullDPS, fastCalcOptions) end local output = cache[node.modKey] if self.powerStat and self.powerStat.stat and not self.powerStat.ignoreForNodes then node.power.singleStat = self:CalculatePowerStat(self.powerStat, output, calcBase) end - nodeIndex = nodeIndex + 1 - if coroutine.running() and GetTime() - start > 100 then - if self.build.powerBuilderProgressCallback then - self.build.powerBuilderProgressCallback(m_floor(nodeIndex/total*100)) - end - coroutine.yield() - start = GetTime() - end + step() end end + -- The heat map is complete from here on self.powerMax = newPowerMax + + for _, job in ipairs(pathJobs) do + job.node.power.pathPower = self:CalculatePowerStat(self.powerStat, calcFunc(job.override, useFullDPS, fastCalcOptions), calcBase) + step() + end self.powerBuilderInitialized = true -- ConPrintf("Power Build time: %d ms", GetTime() - timer_start) end diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index 0a4032a5e3..2ed4bd61f4 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -103,25 +103,34 @@ function calcs.getMiscCalculator(build) env.player.output.FullDPS = fullDPS.combinedDPS env.player.output.FullDotDPS = fullDPS.TotalDotDPS end - local fastEnv + -- One reusable environment per options table, so that interleaved hot loops (gem sorting, + -- node power) never accelerate on state carried over from each other's overrides + local fastEnvs = setmetatable({ }, { __mode = "k" }) return function(override, useFullDPS, fastCalcOptions) if fastCalcOptions then if fastCalcOptions.fullDPSOnly and usedFullDPS and useFullDPS then -- The caller only reads the FullDPS roll-up (e.g. sorting gems by Full DPS), and -- calcFullDPS builds its own environments, so the main-skill pass can be skipped entirely. - -- The base-pass cache store lets skills with unchanged inputs reuse their captured results - local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil, fullDPSCache = { store = fullDPSStore } }) + -- The base-pass cache store lets skills with unchanged inputs reuse their captured results; + -- it only diffs skill-level inputs, so it is valid only while the tree and items are unchanged. + -- Otherwise only skills that cannot deal damage reuse theirs + local treeAndItemsUnchanged = fastCalcOptions.nodeAlloc and fastCalcOptions.requirementsItems + local fullDPSCache = { store = fullDPSStore, inertOnly = not treeAndItemsUnchanged } + local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil, fullDPSCache = fullDPSCache }) return { SkillDPS = fullDPS.skills, FullDPS = fullDPS.combinedDPS, FullDotDPS = fullDPS.TotalDotDPS } end -- Accelerated pass for hot loops (e.g. gem dropdown DPS sorting): reuse the cached -- DBs and environment so unchanged state (tree, items, requirements - per the -- accelerate flags) is carried over instead of being rebuilt for every call. -- The first call builds the reusable environment from scratch, like calcFullDPS does. + local fastEnv = fastEnvs[fastCalcOptions] local accelerate = fastEnv and fastCalcOptions or nil fastEnv = calcs.initEnv(build, "CALCULATOR", override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = fastEnv, accelerate = accelerate }) + fastEnvs[fastCalcOptions] = fastEnv fastEnv.override = override calcs.perform(fastEnv, fastCalcOptions.skipEHP) - if (useFullDPS ~= false or build.viewMode == "TREE") and usedFullDPS then + -- Fast-path callers read only the stat they asked for, so Full DPS is computed only on request + if useFullDPS and usedFullDPS then local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil}) fastEnv.player.output.SkillDPS = fullDPS.skills fastEnv.player.output.FullDPS = fullDPS.combinedDPS @@ -258,6 +267,28 @@ local function getSocketGroupOverride(build, override, socketGroup) return skillOverride end +-- Returns whether a captured pass contributed nothing to Full DPS from a skill that cannot deal +-- damage itself (e.g. meta gems and mirage spawners), so no passive or item change can alter it +local function isInertPass(activeSkill, pass) + local skillFlags = activeSkill.activeEffect.statSet.skillFlags + if skillFlags.hit or skillFlags.dot or activeSkill.minion or activeSkill.mirage then + return false + end + for _, actor in ipairs(pass.actors) do + for _, field in ipairs(harvestFields) do + local value = actor.out[field] + if field == "CullMultiplier" then + if value and value > 1 then + return false + end + elseif value and value ~= 0 then + return false + end + end + end + return true +end + local function findActiveSkillInEnv(env, sourceSkill) for _, candidate in ipairs(env.player.activeSkillList) do if candidate.socketGroup == sourceSkill.socketGroup and candidate.activeEffect.srcInstance == sourceSkill.activeEffect.srcInstance @@ -273,15 +304,17 @@ function calcs.calcFullDPS(build, mode, override, specEnv) -- Optional per-skill result cache driven by input diffing (specEnv.fullDPSCache): -- with capture set, each skill's harvested results are stored in the cache store along -- with its input references (own mod list + the env's coupling surface); on later calls, - -- a skill whose references are unchanged merges its cached results instead of recalculating + -- a skill whose references are unchanged merges its cached results instead of recalculating. + -- With inertOnly set, only skills that cannot deal damage reuse their cached results. local fullDPSCache = specEnv and specEnv.fullDPSCache local cacheStore = fullDPSCache and fullDPSCache.store local surfaceSame = false - if cacheStore then + if cacheStore and not fullDPSCache.inertOnly then local curSurface = captureCouplingSurface(fullEnv) if fullDPSCache.capture then cacheStore.snapshots = { } cacheStore.refs = { } + cacheStore.inert = { } cacheStore.surface = curSurface else surfaceSame = cacheStore.surface ~= nil and surfacesEqual(cacheStore.surface, curSurface) @@ -325,7 +358,9 @@ function calcs.calcFullDPS(build, mode, override, specEnv) local uuid = cacheStore and cacheSkillUUID(activeSkill, skillEnv) local canCacheSkill = not crossSetSkill and not (activeSkill.triggeredBy or activeSkill.skillData.triggered) local cachedPasses - if canCacheSkill and surfaceSame and activeSkill.baseSkillModList then + if canCacheSkill and cacheStore and fullDPSCache.inertOnly then + cachedPasses = cacheStore.inert[uuid] and cacheStore.snapshots[uuid] + elseif canCacheSkill and surfaceSame and activeSkill.baseSkillModList then local ref = cacheStore.refs[uuid] if ref and cacheStore.snapshots[uuid] and modListsEqual(ref, activeSkill.baseSkillModList) then cachedPasses = cacheStore.snapshots[uuid] @@ -406,6 +441,7 @@ function calcs.calcFullDPS(build, mode, override, specEnv) if cacheStore and fullDPSCache.capture and ownRef then cacheStore.snapshots[uuid] = { pass } cacheStore.refs[uuid] = ownRef + cacheStore.inert[uuid] = isInertPass(activeSkill, pass) end local nextSkill diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index d94d05a320..81f796622a 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -126,6 +126,7 @@ end ---@field ignoreForItems? boolean ---@field reverseSort? boolean ---@field itemField string? +---@field needsEHP? boolean The stat is only set by the EHP estimation pass ---@type PowerStat[] data.powerStatList = { @@ -162,13 +163,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", needsEHP=true }, + { stat="SecondMinimalMaximumHitTaken", label="Eff. Maximum Hit Taken", needsEHP=true }, + { stat="PhysicalTakenHit", label="Taken Phys dmg", transform=function(value) return -value end, needsEHP=true }, + { stat="LightningTakenHit", label="Taken Lightning dmg", transform=function(value) return -value end, needsEHP=true }, + { stat="ColdTakenHit", label="Taken Cold dmg", transform=function(value) return -value end, needsEHP=true }, + { stat="FireTakenHit", label="Taken Fire dmg", transform=function(value) return -value end, needsEHP=true }, + { stat="ChaosTakenHit", label="Taken Chaos dmg", transform=function(value) return -value end, needsEHP=true }, { stat="CritChance", label="Crit Chance" }, { stat="CritMultiplier", label="Crit Multiplier" }, { stat="BleedChance", label="Bleed Chance" },