From d317d9e35b2375d2791e411f7a03bad0d25f28f1 Mon Sep 17 00:00:00 2001 From: PersiaDark <94799810+PersiaDark@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:05:24 -0500 Subject: [PATCH 1/2] Exclude energy shield from EHP when a self degen depletes it Righteous Fire and other self-inflicted degens are taken from energy shield before life. When energy shield regeneration is lower than the part of the degen that hits energy shield, energy shield sits at 0 while the degen is active, so it can't absorb hits. Treat it as depleted for the hit pools (EHP, max hit taken) the same way Recoverable ES is capped, respecting ES bypass and Eldritch Battery, with a config option to turn it off. --- spec/System/TestDefence_spec.lua | 74 +++++++++++++++++ src/Modules/CalcDefence.lua | 137 +++++++++++++++++++------------ src/Modules/CalcSections.lua | 2 +- src/Modules/ConfigOptions.lua | 1 + 4 files changed, 162 insertions(+), 52 deletions(-) diff --git a/spec/System/TestDefence_spec.lua b/spec/System/TestDefence_spec.lua index 4fb37d87f8c..4e224dac9a6 100644 --- a/spec/System/TestDefence_spec.lua +++ b/spec/System/TestDefence_spec.lua @@ -1816,4 +1816,78 @@ describe("TestDefence", function() assert.is_true(higherResistEHP >= lowerResistEHP, string.format("EHP fell from %.1f to %.1f", lowerResistEHP, higherResistEHP)) end) + + describe("energy shield depleted by self degen", function() + local function calc(customMods, config) + newBuild() + build.skillsTab:PasteSocketGroup("Righteous Fire 20/0 1") + build.configTab.input.enemyIsBoss = "None" + for k, v in pairs(config or { }) do + build.configTab.input[k] = v + end + build.configTab.input.customMods = customMods + build.configTab:BuildModList() + runCallback("OnFrame") + return build.calcsTab.calcsOutput + end + local esMods = "+1000 to maximum Energy Shield\n" + + it("does not change energy shield without a self degen", function() + newBuild() + build.configTab.input.enemyIsBoss = "None" + build.configTab.input.customMods = esMods + build.configTab:BuildModList() + runCallback("OnFrame") + local output = build.calcsTab.calcsOutput + assert.is_false(output.EnergyShieldDepletedByDegen) + assert.are.equals(output.EnergyShield, output.EnergyShieldRecoveryCap) + end) + + it("excludes energy shield from the hit pools when Righteous Fire drains it", function() + local output = calc(esMods) + assert.is_true(output.FireBuildDegen > 0) + assert.is_true(output.EnergyShield > 0) + assert.is_true(output.EnergyShieldDepletedByDegen) + assert.are.equals(0, output.EnergyShieldRecoveryCap) + -- hit pools now match a build with no energy shield at all + local depletedPool, depletedEHP = output.FireTotalPool, output.TotalEHP + local noES = calc(esMods, { DisableEHPDegenEnergyShieldDepletion = true }) + assert.is_false(noES.EnergyShieldDepletedByDegen) + assert.are.equals(noES.EnergyShield, noES.EnergyShieldRecoveryCap) + assert.are.equals(depletedPool + noES.EnergyShield, noES.FireTotalPool) + assert.is_true(depletedEHP < noES.TotalEHP) + end) + + it("keeps energy shield when its regeneration sustains the degen", function() + local degen = calc(esMods).FireBuildDegen + local output = calc(esMods .. "Regenerate " .. math.ceil(degen) + 1 .. " Energy Shield per second") + assert.is_false(output.EnergyShieldDepletedByDegen) + assert.are.equals(output.EnergyShield, output.EnergyShieldRecoveryCap) + -- partial sustain still drains energy shield + output = calc(esMods .. "Regenerate " .. math.floor(degen / 2) .. " Energy Shield per second") + assert.is_true(output.EnergyShieldDepletedByDegen) + assert.are.equals(0, output.EnergyShieldRecoveryCap) + end) + + it("ignores the part of the degen that bypasses energy shield", function() + local output = calc(esMods .. "All Damage taken bypasses Energy Shield") + assert.is_false(output.EnergyShieldDepletedByDegen) + -- Corrupted Soul style partial bypass: energy shield only takes half of the degen + local degen = calc(esMods).FireBuildDegen + output = calc(esMods .. "50% of Non-Chaos Damage taken bypasses Energy Shield\nRegenerate " .. math.ceil(degen * 0.75) .. " Energy Shield per second") + assert.is_false(output.EnergyShieldDepletedByDegen) + output = calc(esMods .. "50% of Non-Chaos Damage taken bypasses Energy Shield\nRegenerate " .. math.floor(degen * 0.25) .. " Energy Shield per second") + assert.is_true(output.EnergyShieldDepletedByDegen) + end) + + it("only drains energy shield through mana with Eldritch Battery", function() + local output = calc(esMods .. "Energy Shield protects Mana instead of Life") + assert.is_false(output.EnergyShieldDepletedByDegen) + assert.are.equals(output.EnergyShield, output.EnergyShieldRecoveryCap) + output = calc(esMods .. "Energy Shield protects Mana instead of Life\n40% of Damage is taken from Mana before Life") + assert.are.equals(40, output.sharedMindOverMatter) + assert.is_true(output.EnergyShieldDepletedByDegen) + assert.are.equals(0, output.EnergyShieldRecoveryCap) + end) + end) end) diff --git a/src/Modules/CalcDefence.lua b/src/Modules/CalcDefence.lua index 0157e858818..32327f21f01 100644 --- a/src/Modules/CalcDefence.lua +++ b/src/Modules/CalcDefence.lua @@ -2321,6 +2321,59 @@ function calcs.buildDefenceEstimations(env, actor) end end + -- Self-inflicted degens (eg. Righteous Fire). Calculated before the pools so energy shield depletion can be determined + output.TotalBuildDegen = 0 + for _, damageType in ipairs(dmgTypeList) do + local baseVal = modDB:Sum("BASE", nil, damageType.."Degen") + if baseVal > 0 then + for damageConvertedType, convertPercent in pairs(actor.damageOverTimeShiftTable[damageType]) do + if convertPercent > 0 then + local total = baseVal * (convertPercent / 100) * output[damageConvertedType.."TakenDotMult"] + output[damageConvertedType.."BuildDegen"] = (output[damageConvertedType.."BuildDegen"] or 0) + total + output.TotalBuildDegen = output.TotalBuildDegen + total + if breakdown then + breakdown.TotalBuildDegen = breakdown.TotalBuildDegen or { + rowList = { }, + colList = { + { label = "Base Type", key = "type" }, + { label = "Final Type", key = "type2" }, + { label = "Base", key = "base" }, + { label = "Taken As Percent", key = "conv" }, + { label = "Multiplier", key = "mult" }, + { label = "Total", key = "total" }, + } + } + t_insert(breakdown.TotalBuildDegen.rowList, { + type = damageType, + type2 = damageConvertedType, + base = s_format("%.1f", baseVal), + conv = s_format("x %.2f%%", convertPercent), + mult = s_format("x %.2f", output[damageConvertedType.."TakenDotMult"]), + total = s_format("%.1f", total), + }) + breakdown[damageConvertedType.."BuildDegen"] = breakdown[damageConvertedType.."BuildDegen"] or { + rowList = { }, + colList = { + { label = "Base Type", key = "type" }, + { label = "Base", key = "base" }, + { label = "Taken As Percent", key = "conv" }, + { label = "Multiplier", key = "mult" }, + { label = "Total", key = "total" }, + } + } + t_insert(breakdown[damageConvertedType.."BuildDegen"].rowList, { + type = damageType, + base = s_format("%.1f", baseVal), + conv = s_format("x %.2f%%", convertPercent), + mult = s_format("x %.2f", output[damageConvertedType.."TakenDotMult"]), + total = s_format("%.1f", total), + }) + end + end + end + end + end + -- Energy Shield bypass output.AnyBypass = false output.MinimumBypass = 100 @@ -2350,6 +2403,39 @@ function calcs.buildDefenceEstimations(env, actor) output.OnlySharedMindOverMatter = false output.AnySpecificMindOverMatter = false output["sharedMindOverMatter"] = m_min(modDB:Sum("BASE", nil, "DamageTakenFromManaBeforeLife"), 100) + + -- Energy Shield depleted by self-inflicted degens + -- Degens (eg. Righteous Fire) are taken from energy shield before life. If energy shield regeneration can't keep up, + -- energy shield is drained to 0 and stays there while the degen is active, so it can't absorb hits + output.EnergyShieldDepletedByDegen = false + if output.TotalBuildDegen > 0 and output.EnergyShieldRecoveryCap > 0 and not env.configInput.DisableEHPDegenEnergyShieldDepletion then + local energyShieldDegen = 0 + for _, damageType in ipairs(dmgTypeList) do + if output[damageType.."BuildDegen"] then + local energyShieldPortion = 1 - output[damageType.."EnergyShieldBypass"] / 100 + if modDB:Flag(nil, "EnergyShieldProtectsMana") then + -- Eldritch Battery: energy shield only takes the part of the degen taken from mana + local takenFromMana = m_min(modDB:Sum("BASE", nil, damageType.."DamageTakenFromManaBeforeLife"), 100 - output["sharedMindOverMatter"]) + output["sharedMindOverMatter"] + energyShieldPortion = energyShieldPortion * takenFromMana / 100 + end + energyShieldDegen = energyShieldDegen + output[damageType.."BuildDegen"] * energyShieldPortion + end + end + if energyShieldDegen > output.EnergyShieldRegenRecovery then + output.EnergyShieldDepletedByDegen = true + if breakdown then + breakdown.EnergyShieldRecoveryCap = { + s_format("%.1f ^8(energy shield degen)", energyShieldDegen), + s_format("> %.1f ^8(energy shield regen)", output.EnergyShieldRegenRecovery), + "Energy shield is depleted by degen, so it is not counted towards the hit pools", + "^8(this can be disabled in the Configuration tab)", + } + end + output.CappingES = true + output.EnergyShieldRecoveryCap = 0 + end + end + local function calcMoMEBPool(lifePool, MoMEffect, esBypass) local mana = m_max(output.ManaUnreserved or 0, 0) local maxMoMPool = MoMEffect < 1 and lifePool / (1 - MoMEffect) - lifePool or m_huge @@ -3384,57 +3470,6 @@ function calcs.buildDefenceEstimations(env, actor) -- degens do - output.TotalBuildDegen = 0 - for _, damageType in ipairs(dmgTypeList) do - local baseVal = modDB:Sum("BASE", nil, damageType.."Degen") - if baseVal > 0 then - for damageConvertedType, convertPercent in pairs(actor.damageOverTimeShiftTable[damageType]) do - if convertPercent > 0 then - local total = baseVal * (convertPercent / 100) * output[damageConvertedType.."TakenDotMult"] - output[damageConvertedType.."BuildDegen"] = (output[damageConvertedType.."BuildDegen"] or 0) + total - output.TotalBuildDegen = output.TotalBuildDegen + total - if breakdown then - breakdown.TotalBuildDegen = breakdown.TotalBuildDegen or { - rowList = { }, - colList = { - { label = "Base Type", key = "type" }, - { label = "Final Type", key = "type2" }, - { label = "Base", key = "base" }, - { label = "Taken As Percent", key = "conv" }, - { label = "Multiplier", key = "mult" }, - { label = "Total", key = "total" }, - } - } - t_insert(breakdown.TotalBuildDegen.rowList, { - type = damageType, - type2 = damageConvertedType, - base = s_format("%.1f", baseVal), - conv = s_format("x %.2f%%", convertPercent), - mult = s_format("x %.2f", output[damageConvertedType.."TakenDotMult"]), - total = s_format("%.1f", total), - }) - breakdown[damageConvertedType.."BuildDegen"] = breakdown[damageConvertedType.."BuildDegen"] or { - rowList = { }, - colList = { - { label = "Base Type", key = "type" }, - { label = "Base", key = "base" }, - { label = "Taken As Percent", key = "conv" }, - { label = "Multiplier", key = "mult" }, - { label = "Total", key = "total" }, - } - } - t_insert(breakdown[damageConvertedType.."BuildDegen"].rowList, { - type = damageType, - base = s_format("%.1f", baseVal), - conv = s_format("x %.2f%%", convertPercent), - mult = s_format("x %.2f", output[damageConvertedType.."TakenDotMult"]), - total = s_format("%.1f", total), - }) - end - end - end - end - end if output.TotalBuildDegen == 0 then output.TotalBuildDegen = nil else diff --git a/src/Modules/CalcSections.lua b/src/Modules/CalcSections.lua index 0f5b1c7f1ca..8f3a0ea553c 100644 --- a/src/Modules/CalcSections.lua +++ b/src/Modules/CalcSections.lua @@ -1582,7 +1582,7 @@ return { { label = "Total Increased", { format = "{0:mod:1}%", { modName = { "EnergyShield", "Defences" }, modType = "INC" }, }, }, { label = "Total More", { format = "{0:mod:1}%", { modName = { "EnergyShield", "Defences" }, modType = "MORE" }, }, }, { label = "Total", { format = "{0:output:EnergyShield}", { breakdown = "EnergyShield" }, }, }, - { label = "Total Recoverable", haveOutput = "CappingES", { format = "{0:output:EnergyShieldRecoveryCap}", { breakdown = "EnergyShield" }, }, }, + { label = "Total Recoverable", haveOutput = "CappingES", { format = "{0:output:EnergyShieldRecoveryCap}", { breakdown = "EnergyShieldRecoveryCap" }, { breakdown = "EnergyShield" }, }, }, { label = "Recharge Rate", haveOutput = "EnergyShieldRechargeAppliesToEnergyShield", { format = "{1:output:EnergyShieldRecharge}", { breakdown = "EnergyShieldRecharge" }, { modName = { "EnergyShieldRecharge", "EnergyShieldRecoveryRate", "NoEnergyShieldRecharge", "CannotGainEnergyShield" }, }, diff --git a/src/Modules/ConfigOptions.lua b/src/Modules/ConfigOptions.lua index 8ff3e6f8c79..449dd8e3ef1 100644 --- a/src/Modules/ConfigOptions.lua +++ b/src/Modules/ConfigOptions.lua @@ -224,6 +224,7 @@ return { end end }, { var = "EHPUnluckyWorstOf", type = "list", label = "EHP calc unlucky:", tooltip = "Sets the EHP calc to pretend its unlucky and reduce the effects of random events such as\n\tBlock/Spell Block Chance\n\tDodge/Spell Dodge Chance\n\tSpell Suppression Chance\n\tAvoidance Chance", list = {{val=1,label="Average"},{val=2,label="Unlucky"},{val=4,label="Very Unlucky"}} }, + { var = "DisableEHPDegenEnergyShieldDepletion", type = "check", label = "Disable EHP ^x88FFFFES ^7depletion by self degen:", ifMod = {"PhysicalDegen", "LightningDegen", "ColdDegen", "FireDegen", "ChaosDegen"}, tooltip = "When a self-inflicted degen (eg. Righteous Fire) takes more ^x88FFFFEnergy Shield ^7per second than you regenerate,\n^x88FFFFEnergy Shield ^7is treated as depleted and not counted in EHP / Maximum Hit Taken.\nEnable this if something not included in ^x88FFFFEnergy Shield ^7regeneration (eg. leech) keeps it up."}, { var = "DisableEHPGainOnBlock", type = "check", label = "Disable EHP gain when hit:", ifMod = {"LifeOnBlock", "ManaOnBlock", "EnergyShieldOnBlock", "EnergyShieldOnSpellBlock", "LifeOnSuppress", "EnergyShieldOnSuppress", "MissingLifeBeforeEnemyHit", "MissingManaBeforeEnemyHit"}, tooltip = "Sets the EHP calc to not apply gain on block, suppress or Defiance of Destiny effects"}, { var = "armourCalculationMode", type = "list", label = "Armour calculation mode:", ifCond = { "ArmourMax", "ArmourAvg" }, tooltip = "Controls how Defending with Double Armour is calculated:\n\tMinimum: never Defend with Double Armour\n\tAverage: Damage Reduction from Defending with Double Armour is proportional to chance\n\tMaximum: always Defend with Double Armour\nThis setting has no effect if you have 100% chance to Defend with Double Armour.", list = {{val="MIN",label="Minimum"},{val="AVERAGE",label="Average"},{val="MAX",label="Maximum"}}, apply = function(val, modList, enemyModList) if val == "MAX" then From 34cbcc1d9f477d2a002c04932343d2e51e393986 Mon Sep 17 00:00:00 2001 From: PersiaDark <94799810+PersiaDark@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:05:24 -0500 Subject: [PATCH 2/2] Add Zealot's Oath and Chaos Inoculation specs for energy shield depletion --- spec/System/TestDefence_spec.lua | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/spec/System/TestDefence_spec.lua b/spec/System/TestDefence_spec.lua index 4e224dac9a6..c12ffdb46af 100644 --- a/spec/System/TestDefence_spec.lua +++ b/spec/System/TestDefence_spec.lua @@ -1880,6 +1880,25 @@ describe("TestDefence", function() assert.is_true(output.EnergyShieldDepletedByDegen) end) + it("uses life regeneration converted by Zealot's Oath as energy shield sustain", function() + local degen = calc(esMods .. "Zealot's Oath").FireBuildDegen + local output = calc(esMods .. "Zealot's Oath\nRegenerate " .. math.ceil(degen) + 1 .. " Life per second") + assert.is_true(output.EnergyShieldRegenRecovery > degen) + assert.is_false(output.EnergyShieldDepletedByDegen) + assert.are.equals(output.EnergyShield, output.EnergyShieldRecoveryCap) + output = calc(esMods .. "Zealot's Oath\nRegenerate " .. math.floor(degen / 2) .. " Life per second") + assert.is_true(output.EnergyShieldDepletedByDegen) + assert.are.equals(0, output.EnergyShieldRecoveryCap) + end) + + it("does not change energy shield with Chaos Inoculation", function() + local output = calc(esMods .. "Chaos Inoculation") + assert.are.equals(1, output.Life) + assert.is_nil(output.TotalBuildDegen) + assert.is_false(output.EnergyShieldDepletedByDegen) + assert.are.equals(output.EnergyShield, output.EnergyShieldRecoveryCap) + end) + it("only drains energy shield through mana with Eldritch Battery", function() local output = calc(esMods .. "Energy Shield protects Mana instead of Life") assert.is_false(output.EnergyShieldDepletedByDegen)