From 5ffd9b6ab41ab8cd450799a7f054ae66e28ddd73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=A0i=C5=A1ka?= Date: Sat, 3 Oct 2026 11:30:07 +0200 Subject: [PATCH 1/2] Add per damage type Max Hit to power stat options Node power, the Power Report and the other power stat selectors only offered "Eff. Maximum Hit Taken" (the second-lowest max hit across all damage types) and the "Taken X dmg" stats, which ignore life/ES pools. Add Physical, Lightning, Cold, Fire and Chaos Max Hit so a single damage type's maximum survivable hit can be optimised directly. Minion variants are generated automatically. Co-Authored-By: Claude Opus 5.5 --- spec/System/TestMaxHitPowerStats_spec.lua | 69 +++++++++++++++++++++++ src/Modules/Data.lua | 5 ++ 2 files changed, 74 insertions(+) create mode 100644 spec/System/TestMaxHitPowerStats_spec.lua diff --git a/spec/System/TestMaxHitPowerStats_spec.lua b/spec/System/TestMaxHitPowerStats_spec.lua new file mode 100644 index 0000000000..ae6d9b831b --- /dev/null +++ b/spec/System/TestMaxHitPowerStats_spec.lua @@ -0,0 +1,69 @@ +describe("TestMaxHitPowerStats", function() + before_each(function() + newBuild() + end) + + local maxHitStats = { + { stat = "PhysicalMaximumHitTaken", label = "Physical Max Hit" }, + { stat = "LightningMaximumHitTaken", label = "Lightning Max Hit" }, + { stat = "ColdMaximumHitTaken", label = "Cold Max Hit" }, + { stat = "FireMaximumHitTaken", label = "Fire Max Hit" }, + { stat = "ChaosMaximumHitTaken", label = "Chaos Max Hit" }, + } + + local function findPowerStat(list, stat) + for _, entry in ipairs(list) do + if entry.stat == stat then + return entry + end + end + end + + local function findLifeNode() + local nodeIds = { } + for id, node in pairs(build.spec.nodes) do + if node.type == "Normal" and not node.alloc then + for _, line in ipairs(node.sd or { }) do + if line:match("^%d+%% increased maximum Life$") then + table.insert(nodeIds, id) + break + end + end + end + end + table.sort(nodeIds) + return build.spec.nodes[nodeIds[1]] + end + + it("offers per damage type max hit in the passive tree power selector", function() + for _, expected in ipairs(maxHitStats) do + local entry = findPowerStat(build.treeTab.powerStatList, expected.stat) + assert.is_not_nil(entry, expected.stat .. " missing from tree power stats") + assert.are.equals(expected.label, entry.label) + end + end) + + it("offers minion variants of per damage type max hit", function() + for _, expected in ipairs(maxHitStats) do + local entry = findPowerStat(data.powerStatList, "Minion" .. expected.stat) + assert.is_not_nil(entry, "Minion" .. expected.stat .. " missing from power stats") + assert.are.equals("Minion " .. expected.label, entry.label) + end + end) + + it("scores a maximum Life node as positive power for every damage type", function() + build.characterLevel = 90 + build.characterLevelAutoMode = false + runCallback("OnFrame") + local lifeNode = findLifeNode() + assert.is_not_nil(lifeNode, "no unallocated maximum Life node found") + local calcFunc, calcBase = build.calcsTab:GetMiscCalculator() + local output = calcFunc({ addNodes = { [lifeNode] = true } }) + + for _, expected in ipairs(maxHitStats) do + local entry = findPowerStat(build.treeTab.powerStatList, expected.stat) + assert.is_not_nil(entry, expected.stat .. " missing from tree power stats") + assert.is_true(build.calcsTab:CalculatePowerStat(entry, output, calcBase) > 0, expected.stat .. " power should be positive") + end + end) +end) diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index d94d05a320..546ec1ef6d 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -164,6 +164,11 @@ data.powerStatList = { { stat="ProjectileAvoidChance", label="Projectile avoid chance" }, { stat="TotalEHP", label="Effective Hit Pool" }, { stat="SecondMinimalMaximumHitTaken", label="Eff. Maximum Hit Taken" }, + { stat="PhysicalMaximumHitTaken", label="Physical Max Hit" }, + { stat="LightningMaximumHitTaken", label="Lightning Max Hit" }, + { stat="ColdMaximumHitTaken", label="Cold Max Hit" }, + { stat="FireMaximumHitTaken", label="Fire Max Hit" }, + { stat="ChaosMaximumHitTaken", label="Chaos Max Hit" }, { 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 }, From cfb53f96af9c125ab9954859047f4000a84defab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=A0i=C5=A1ka?= Date: Sat, 3 Oct 2026 13:06:48 +0200 Subject: [PATCH 2/2] Recalculate at level 90 in max hit power spec Changing build.characterLevel without setting build.buildFlag left the misc calculator's base output at level 1, so every node scored the level difference instead of its own effect. Force the recalc, and check that a Fire Resistance node only scores for Fire Max Hit. Co-Authored-By: Claude Opus 5.5 --- spec/System/TestMaxHitPowerStats_spec.lua | 48 +++++++++++++++++------ 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/spec/System/TestMaxHitPowerStats_spec.lua b/spec/System/TestMaxHitPowerStats_spec.lua index ae6d9b831b..f4ad2c9818 100644 --- a/spec/System/TestMaxHitPowerStats_spec.lua +++ b/spec/System/TestMaxHitPowerStats_spec.lua @@ -19,12 +19,13 @@ describe("TestMaxHitPowerStats", function() end end - local function findLifeNode() + -- first unallocated small passive (by id) with a stat line matching the pattern + local function findNode(pattern) local nodeIds = { } for id, node in pairs(build.spec.nodes) do if node.type == "Normal" and not node.alloc then for _, line in ipairs(node.sd or { }) do - if line:match("^%d+%% increased maximum Life$") then + if line:match(pattern) then table.insert(nodeIds, id) break end @@ -35,6 +36,23 @@ describe("TestMaxHitPowerStats", function() return build.spec.nodes[nodeIds[1]] end + -- power of each max hit stat when the node is added, as the node power sweep calculates it + local function maxHitPowers(node) + build.characterLevel = 90 + build.characterLevelAutoMode = false + build.buildFlag = true + runCallback("OnFrame") + local calcFunc, calcBase = build.calcsTab:GetMiscCalculator() + local output = calcFunc({ addNodes = { [node] = true } }) + local powers = { } + for _, expected in ipairs(maxHitStats) do + local entry = findPowerStat(build.treeTab.powerStatList, expected.stat) + assert.is_not_nil(entry, expected.stat .. " missing from tree power stats") + powers[expected.stat] = build.calcsTab:CalculatePowerStat(entry, output, calcBase) + end + return powers + end + it("offers per damage type max hit in the passive tree power selector", function() for _, expected in ipairs(maxHitStats) do local entry = findPowerStat(build.treeTab.powerStatList, expected.stat) @@ -52,18 +70,26 @@ describe("TestMaxHitPowerStats", function() end) it("scores a maximum Life node as positive power for every damage type", function() - build.characterLevel = 90 - build.characterLevelAutoMode = false - runCallback("OnFrame") - local lifeNode = findLifeNode() + local lifeNode = findNode("^%d+%% increased maximum Life$") assert.is_not_nil(lifeNode, "no unallocated maximum Life node found") - local calcFunc, calcBase = build.calcsTab:GetMiscCalculator() - local output = calcFunc({ addNodes = { [lifeNode] = true } }) + local powers = maxHitPowers(lifeNode) for _, expected in ipairs(maxHitStats) do - local entry = findPowerStat(build.treeTab.powerStatList, expected.stat) - assert.is_not_nil(entry, expected.stat .. " missing from tree power stats") - assert.is_true(build.calcsTab:CalculatePowerStat(entry, output, calcBase) > 0, expected.stat .. " power should be positive") + assert.is_true(powers[expected.stat] > 0, expected.stat .. " power should be positive") + end + end) + + it("scores a Fire Resistance node only for Fire Max Hit", function() + local fireResNode = findNode("^%+%d+%% to Fire Resistance$") + assert.is_not_nil(fireResNode, "no unallocated Fire Resistance node found") + local powers = maxHitPowers(fireResNode) + + for _, expected in ipairs(maxHitStats) do + if expected.stat == "FireMaximumHitTaken" then + assert.is_true(powers[expected.stat] > 0, expected.stat .. " power should be positive") + else + assert.are.equals(0, powers[expected.stat], expected.stat .. " power should be zero") + end end end) end)