Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions spec/System/TestNodePower_spec.lua
Original file line number Diff line number Diff line change
@@ -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)
54 changes: 33 additions & 21 deletions src/Classes/CalcsTab.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = { }
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down
50 changes: 43 additions & 7 deletions src/Modules/Calcs.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
15 changes: 8 additions & 7 deletions src/Modules/Data.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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" },
Expand Down