From 54c35a243f77978d23cb4a402deea1ca92c75d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=A0i=C5=A1ka?= Date: Mon, 28 Sep 2026 20:35:08 +0200 Subject: [PATCH 1/2] Model partial Heavy Stun uptime and Crushing Blows The "Is the enemy Heavy Stunned?" checkbox is now a list: No, Always, Custom % and Calculated. With Custom % and Calculated the offence calculations run against a Heavy Stunned and a normal enemy, and the numeric outputs are averaged by the uptime. The run state is snapshotted and restored between the two runs so no mod is applied twice. Calculated uptime = D / (D + t), where D is the Heavy Stun duration (2s monster base, scaled by stun duration, chance to double stun duration and enemy stun recovery) and t is the hits needed divided by landed hits per second. Hits needed is ceil(100 / buildup per hit). With Crushing Blows it is the fewer of that and the hits to reach the Primed for Stun threshold (40/60/70% for normal/rare/unique) plus one Crushing Blow. Only the first Heavy Stun is modelled. "Your Hits are Crushing Blows" (Titan's Crushing Impacts) and Syzygy's Crushing Blows against Ignited and Fully Armour Broken enemies are now parsed. Old builds with the checkbox ticked load as Always. Co-Authored-By: Claude Opus 5.5 --- spec/System/TestHeavyStun_spec.lua | 304 +++++++++++++++++++++++++++++ src/Classes/ConfigTab.lua | 15 +- src/Data/ModCache.lua | 2 +- src/Data/SkillStatMap.lua | 3 + src/Modules/CalcOffence.lua | 197 ++++++++++++++++++- src/Modules/CalcSections.lua | 12 ++ src/Modules/ConfigOptions.lua | 13 +- src/Modules/ModParser.lua | 1 + 8 files changed, 538 insertions(+), 9 deletions(-) create mode 100644 spec/System/TestHeavyStun_spec.lua diff --git a/spec/System/TestHeavyStun_spec.lua b/spec/System/TestHeavyStun_spec.lua new file mode 100644 index 0000000000..9d337e40eb --- /dev/null +++ b/spec/System/TestHeavyStun_spec.lua @@ -0,0 +1,304 @@ +describe("TestHeavyStun", function() + before_each(function() + newBuild() + end) + + teardown(function() + -- newBuild() takes care of resetting everything in setup() + end) + + local function equipMace() + build.itemsTab:CreateDisplayItemFromRaw([[ + New Item + Felled Greatclub + ]]) + build.itemsTab:AddDisplayItem() + end + + local function setConfig(input, customMods) + for var, val in pairs(input) do + build.configTab.input[var] = val + end + build.configTab.input.customMods = customMods or "" + build.configTab:BuildModList() + runCallback("OnFrame") + end + + local function setupMaceStrike() + equipMace() + build.skillsTab:PasteSocketGroup("skillId:Melee2HMacePlayer Mace Strike 20/0 1") + build.mainSocketGroup = 1 + end + + local function mainSkill() + return build.calcsTab.mainEnv.player.mainSkill + end + + describe("Crushing Blows", function() + it("parses Your Hits are Crushing Blows", function() + setupMaceStrike() + setConfig({ }, "Your Hits are Crushing Blows") + + assert.is_true(mainSkill().skillModList:Flag(mainSkill().skillCfg, "CrushingBlows")) + end) + + it("makes Syzygy hits Crushing Blows only against Ignited and Fully Armour Broken enemies", function() + equipMace() + build.skillsTab:PasteSocketGroup("Earthquake 20/0 1\nSyzygy 1/0 1") + setConfig({ }) + assert.is_true(not mainSkill().skillModList:Flag(mainSkill().skillCfg, "CrushingBlows")) + + setConfig({ conditionEnemyIgnited = true, conditionEnemyArmourBroken = true }) + assert.is_true(mainSkill().skillModList:Flag(mainSkill().skillCfg, "CrushingBlows")) + end) + end) + + describe("configuration", function() + it("loads the old Heavy Stunned checkbox as Always", function() + build.configTab:Load({ attrib = { }, { elem = "Input", attrib = { name = "conditionEnemyHeavyStunned", boolean = "true" } } }, "test") + + assert.are.equals("ALWAYS", build.configTab.configSets[1].input.enemyHeavyStunMode) + assert.is_nil(build.configTab.configSets[1].input.conditionEnemyHeavyStunned) + end) + + it("shows the uptime field only for Custom %", function() + setupMaceStrike() + local stunMod = "100% more Damage against Heavy Stunned Enemies" + local control = build.configTab.varControls.enemyHeavyStunUptime + + setConfig({ enemyHeavyStunMode = "CALCULATED" }, stunMod) + assert.is_false(control.shown()) + + setConfig({ enemyHeavyStunMode = "CUSTOM" }, stunMod) + assert.is_true(control.shown()) + end) + + it("treats the enemy as Heavy Stunned all the time for Always", function() + setupMaceStrike() + local stunMod = "100% more Damage against Heavy Stunned Enemies" + + setConfig({ enemyHeavyStunMode = "NONE" }, stunMod) + local notStunnedHit = build.calcsTab.mainOutput.AverageDamage + setConfig({ enemyHeavyStunMode = "ALWAYS" }, stunMod) + + assert.are.equals(round(notStunnedHit * 2, 4), round(build.calcsTab.mainOutput.AverageDamage, 4)) + end) + end) + + describe("uptime blend", function() + local stunMod = "100% more Damage against Heavy Stunned Enemies\nAdds 100 to 100 Physical Damage to Attacks" + + local function numericOutputs(output) + local values = { } + for stat, value in pairs(output) do + if type(value) == "number" then + values[stat] = value + end + end + for _, hand in ipairs({ "MainHand", "OffHand" }) do + for stat, value in pairs(output[hand] or { }) do + if type(value) == "number" then + values[hand.."."..stat] = value + end + end + end + return values + end + + local function assertSameOutputs(expected, actual) + for stat, value in pairs(expected) do + assert.are.equals(stat.."="..tostring(value), stat.."="..tostring(actual[stat])) + end + end + + it("averages damage against a Heavy Stunned and a normal enemy by the custom uptime", function() + setupMaceStrike() + setConfig({ enemyHeavyStunMode = "NONE" }, stunMod) + local normalDPS = build.calcsTab.mainOutput.CombinedDPS + setConfig({ enemyHeavyStunMode = "ALWAYS" }, stunMod) + local stunnedDPS = build.calcsTab.mainOutput.CombinedDPS + assert.is_true(stunnedDPS > normalDPS) + + setConfig({ enemyHeavyStunMode = "CUSTOM", enemyHeavyStunUptime = 40 }, stunMod) + + assert.are.equals(round(0.4 * stunnedDPS + 0.6 * normalDPS, 6), round(build.calcsTab.mainOutput.CombinedDPS, 6)) + end) + + it("matches No at 0% and Always at 100% custom uptime", function() + setupMaceStrike() + setConfig({ enemyHeavyStunMode = "NONE" }, stunMod) + local normal = numericOutputs(build.calcsTab.mainOutput) + setConfig({ enemyHeavyStunMode = "ALWAYS" }, stunMod) + local stunned = numericOutputs(build.calcsTab.mainOutput) + + setConfig({ enemyHeavyStunMode = "CUSTOM", enemyHeavyStunUptime = 0 }, stunMod) + assertSameOutputs(normal, numericOutputs(build.calcsTab.mainOutput)) + setConfig({ enemyHeavyStunMode = "CUSTOM", enemyHeavyStunUptime = 100 }, stunMod) + assert.are.equals(round(stunned.CombinedDPS, 6), round(build.calcsTab.mainOutput.CombinedDPS, 6)) + end) + + it("does not apply offence mods twice when blending", function() + equipMace() + build.skillsTab:PasteSocketGroup("skillId:Melee2HMacePlayer Mace Strike 20/0 1\nRuthless 1/0 1") + build.mainSocketGroup = 1 + setConfig({ enemyHeavyStunMode = "NONE" }) + local normal = numericOutputs(build.calcsTab.mainOutput) + + setConfig({ enemyHeavyStunMode = "CUSTOM", enemyHeavyStunUptime = 50 }) + + assertSameOutputs(normal, numericOutputs(build.calcsTab.mainOutput)) + end) + + it("does not apply spell offence mods twice when blending", function() + build.skillsTab:PasteSocketGroup("Fireball 20/0 1") + build.mainSocketGroup = 1 + setConfig({ enemyHeavyStunMode = "NONE" }) + local normal = numericOutputs(build.calcsTab.mainOutput) + + setConfig({ enemyHeavyStunMode = "CUSTOM", enemyHeavyStunUptime = 50 }) + + assertSameOutputs(normal, numericOutputs(build.calcsTab.mainOutput)) + end) + + it("does not apply minion offence mods twice when blending", function() + build.skillsTab:PasteSocketGroup("Skeletal Sniper 20/0 1") + build.mainSocketGroup = 1 + setConfig({ enemyHeavyStunMode = "NONE" }) + local normal = numericOutputs(build.calcsTab.mainEnv.minion.output) + + setConfig({ enemyHeavyStunMode = "CUSTOM", enemyHeavyStunUptime = 50 }) + + assertSameOutputs(normal, numericOutputs(build.calcsTab.mainEnv.minion.output)) + end) + + it("averages damage by the calculated uptime", function() + setupMaceStrike() + local enemy = { enemyIsBoss = "None", enemyLevel = 20 } + enemy.enemyHeavyStunMode = "NONE" + setConfig(enemy, stunMod) + local normalDPS = build.calcsTab.mainOutput.CombinedDPS + enemy.enemyHeavyStunMode = "ALWAYS" + setConfig(enemy, stunMod) + local stunnedDPS = build.calcsTab.mainOutput.CombinedDPS + + enemy.enemyHeavyStunMode = "CALCULATED" + setConfig(enemy, stunMod) + local output = build.calcsTab.mainOutput + + assert.is_true(output.HeavyStunUptime > 0 and output.HeavyStunUptime < 100) + assert.are.equals(round(normalDPS + (stunnedDPS - normalDPS) * output.HeavyStunUptime / 100, 6), round(output.CombinedDPS, 6)) + end) + + it("leaves the enemy not Heavy Stunned after blending", function() + setupMaceStrike() + setConfig({ enemyHeavyStunMode = "CUSTOM", enemyHeavyStunUptime = 50 }, stunMod) + + assert.is_true(not build.calcsTab.mainEnv.enemy.modDB:Flag(nil, "Condition:HeavyStunned")) + end) + end) + + describe("calculated uptime", function() + -- A normal level 20 enemy takes about 27% Heavy Stun buildup per hit from this mace + local baseMods = "Adds 100 to 100 Physical Damage to Attacks" + + local function calculate(customMods, enemy) + local input = { enemyHeavyStunMode = "CALCULATED", enemyIsBoss = "None", enemyLevel = 20, conditionEnemyRareOrUnique = false } + for var, val in pairs(enemy or { }) do + input[var] = val + end + setConfig(input, baseMods .. "\n" .. (customMods or "")) + return build.calcsTab.mainOutput + end + + local function hitRate(output) + return (output.HitSpeed or output.Speed) * output.DpsMultiplier * output.HitChance / 100 + end + + before_each(function() + setupMaceStrike() + end) + + it("needs enough hits to fill the Heavy Stun bar", function() + local output = calculate() + + assert.is_true(output.HeavyStunBuildupAvg > 10 and output.HeavyStunBuildupAvg < 50) + assert.are.equals(math.ceil(100 / output.HeavyStunBuildupAvg), output.HitsToHeavyStun) + end) + + it("needs fewer hits with more Stun buildup", function() + local base = calculate() + local baseHits = base.HitsToHeavyStun + local output = calculate("200% increased Stun Buildup") + + assert.is_true(output.HitsToHeavyStun < baseHits) + assert.are.equals(math.ceil(100 / output.HeavyStunBuildupAvg), output.HitsToHeavyStun) + end) + + it("reaches the Heavy Stun sooner with more attack speed", function() + local base = calculate() + local baseHits, baseTime, baseUptime = base.HitsToHeavyStun, base.TimeToHeavyStun, base.HeavyStunUptime + local output = calculate("50% increased Attack Speed") + + assert.are.equals(baseHits, output.HitsToHeavyStun) + assert.are.equals(round(output.HitsToHeavyStun / hitRate(output), 6), round(output.TimeToHeavyStun, 6)) + assert.is_true(output.TimeToHeavyStun < baseTime) + assert.is_true(output.HeavyStunUptime > baseUptime) + end) + + it("keeps the enemy Heavy Stunned longer with more Stun duration", function() + local base = calculate() + local baseUptime = base.HeavyStunUptime + assert.are.equals(2, base.HeavyStunDuration) + + local output = calculate("100% increased Stun Duration") + + assert.are.equals(4, output.HeavyStunDuration) + assert.is_true(output.HeavyStunUptime > baseUptime) + end) + + it("counts the chance to double Stun duration as an average", function() + local output = calculate("25% chance to double Stun Duration") + + assert.are.equals(2.5, output.HeavyStunDuration) + end) + + it("is the Heavy Stun duration over the whole cycle", function() + local output = calculate() + local expected = output.HeavyStunDuration / (output.HeavyStunDuration + output.HitsToHeavyStun / hitRate(output)) * 100 + + assert.are.equals(round(expected, 6), round(output.HeavyStunUptime, 6)) + assert.are.equals(round(output.HitsToHeavyStun / hitRate(output), 6), round(output.TimeToHeavyStun, 6)) + end) + + it("Heavy Stuns a Primed normal enemy with the next Crushing Blow", function() + local output = calculate("Your Hits are Crushing Blows") + local buildup = output.HeavyStunBuildupAvg + + assert.are.equals(math.min(math.ceil(100 / buildup), math.ceil(40 / buildup) + 1), output.HitsToHeavyStun) + assert.are.equals(3, output.HitsToHeavyStun) + end) + + it("uses the Primed threshold of rare and unique enemies for Crushing Blows", function() + local rare = calculate("Your Hits are Crushing Blows", { conditionEnemyRareOrUnique = true }) + assert.are.equals(math.min(math.ceil(100 / rare.HeavyStunBuildupAvg), math.ceil(60 / rare.HeavyStunBuildupAvg) + 1), rare.HitsToHeavyStun) + + local unique = calculate("Your Hits are Crushing Blows\n1000% increased Stun Buildup", { enemyIsBoss = "Boss" }) + assert.are.equals(math.min(math.ceil(100 / unique.HeavyStunBuildupAvg), math.ceil(70 / unique.HeavyStunBuildupAvg) + 1), unique.HitsToHeavyStun) + end) + + it("estimates the uptime when the enemy is not Heavy Stunned, but not when it always is", function() + assert.is_true(calculate(nil, { enemyHeavyStunMode = "NONE" }).HeavyStunUptime > 0) + + assert.is_nil(calculate(nil, { enemyHeavyStunMode = "ALWAYS" }).HitsToHeavyStun) + end) + + it("explains the uptime in the Calcs tab", function() + calculate("Your Hits are Crushing Blows") + build.calcsTab:BuildOutput() + + assert.is_not_nil(build.calcsTab.calcsEnv.player.breakdown.HeavyStunUptime) + assert.is_not_nil(build.calcsTab.calcsEnv.player.breakdown.HitsToHeavyStun) + assert.is_not_nil(build.calcsTab.calcsEnv.player.breakdown.HeavyStunDuration) + end) + end) +end) diff --git a/src/Classes/ConfigTab.lua b/src/Classes/ConfigTab.lua index 6c57a9c330..1f7b131c5d 100644 --- a/src/Classes/ConfigTab.lua +++ b/src/Classes/ConfigTab.lua @@ -405,7 +405,11 @@ function ConfigTabClass:ConfigTab(build) end if varData.ifOption then t_insert(shownFuncs, listOrSingleIfOption(varData.ifOption, function(ifOption) - return self.configSets[self.activeConfigSetId].input[ifOption] + local value = self.configSets[self.activeConfigSetId].input[ifOption] + if varData.ifOptionValue ~= nil then + return value == varData.ifOptionValue + end + return value end)) end if varData.ifCond then @@ -899,7 +903,14 @@ function ConfigTabClass:Load(xml, fileName) self.configSets[configSetId].input[node.attrib.name] = node.attrib.string end elseif node.attrib.boolean then - self.configSets[configSetId].input[node.attrib.name] = node.attrib.boolean == "true" + -- backwards compat: the Heavy Stunned checkbox became the Heavy Stun uptime mode list + if node.attrib.name == "conditionEnemyHeavyStunned" then + if node.attrib.boolean == "true" then + self.configSets[configSetId].input.enemyHeavyStunMode = "ALWAYS" + end + else + self.configSets[configSetId].input[node.attrib.name] = node.attrib.boolean == "true" + end else launch:ShowErrMsg("^1Error parsing '%s': 'Input' element missing number, string or boolean attribute", fileName) return true diff --git a/src/Data/ModCache.lua b/src/Data/ModCache.lua index 50484e61c0..3c35df282e 100644 --- a/src/Data/ModCache.lua +++ b/src/Data/ModCache.lua @@ -9969,7 +9969,7 @@ c["Your Heavy Stun buildup empties 1% faster per 10 Tribute 5% increased Armour, c["Your Heavy Stun buildup empties 35% faster"]={nil,"Your Heavy Stun buildup empties 35% faster "} c["Your Heavy Stun buildup empties 50% faster"]={nil,"Your Heavy Stun buildup empties 50% faster "} c["Your Heavy Stun buildup empties 50% faster if you've successfully Parried Recently"]={nil,"Your Heavy Stun buildup empties 50% faster if you've successfully Parried Recently "} -c["Your Hits are Crushing Blows"]={nil,"Your Hits are Crushing Blows "} +c["Your Hits are Crushing Blows"]={{[1]={flags=0,keywordFlags=0,name="CrushingBlows",type="FLAG",value=true}},nil} c["Your Hits can Penetrate Elemental Resistances down to a minimum of -50%"]={{[1]={flags=0,keywordFlags=262144,name="ElementalPenetrationMinimum",type="BASE",value=-50}},nil} c["Your Hits can only Kill Frozen Enemies"]={nil,"Your Hits can only Kill Frozen Enemies "} c["Your Hits cannot Stun enemies"]={nil,"Your Hits cannot Stun enemies "} diff --git a/src/Data/SkillStatMap.lua b/src/Data/SkillStatMap.lua index 3346810c6c..7d688f1bb4 100644 --- a/src/Data/SkillStatMap.lua +++ b/src/Data/SkillStatMap.lua @@ -1576,6 +1576,9 @@ return function(mod, flag, skill) flag("CannotHeavyStun"), flag("CannotStun"), }, +["crushing_blow_vs_ignited_and_fully_armour_broken"] = { + flag("CrushingBlows", { type = "ActorCondition", actor = "enemy", var = "Ignited" }, { type = "ActorCondition", actor = "enemy", var = "ArmourFullyBroken" }), +}, ["cannot_cause_bleeding"] = { flag("CannotBleed"), }, diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 960a5e16d4..648e59a9bd 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -520,11 +520,11 @@ local function getBaseLeech(resource, dmgType, skillModList, cfg, enemyDB) end return leech and leech or 0 end --- Performs all offensive calculations +-- Performs all offensive calculations for one state of the enemy ---@param env Env ---@param actor Actor ---@param activeSkill ActiveSkill -function calcs.offence(env, actor, activeSkill) +local function calcOffence(env, actor, activeSkill) local modDB = actor.modDB local enemyDB = actor.enemy.modDB ---@class Output @@ -6469,3 +6469,196 @@ function calcs.offence(env, actor, activeSkill) output.ReservationDPS = output.CombinedDPS * (output.ReservationDpsMultiplier - 1) output.CombinedDPS = output.CombinedDPS * bestCull * output.ReservationDpsMultiplier end + +-- Records everything the offence calculations change, so that they can be run again from the same state +local function snapshotOffenceState(env, actor, activeSkill) + local snapshots = { } + local seen = { } + local function addTable(tbl) + if type(tbl) == "table" and not seen[tbl] then + seen[tbl] = true + t_insert(snapshots, { tbl = tbl, copy = copyTable(tbl, true) }) + end + end + local function addModStore(store) + if type(store) ~= "table" or seen[store] then + return + end + addTable(store) -- a ModList keeps its mods in the store itself + addTable(store.conditions) + addTable(store.multipliers) + if store.mods then + addTable(store.mods) + for _, modList in pairs(store.mods) do + addTable(modList) + end + end + addModStore(store.parent) + end + for _, skill in ipairs({ activeSkill, env.player.mainSkill }) do + addModStore(skill.skillModList) + addTable(skill) + addTable(skill.skillData) + addTable(skill.skillCfg) + addTable(skill.skillCfg and skill.skillCfg.skillCond) + addTable(skill.weapon1Cfg) + addTable(skill.weapon2Cfg) + addTable(skill.activeEffect.statSet and skill.activeEffect.statSet.skillFlags) + addTable(skill.activeEffect.statSetCalcs and skill.activeEffect.statSetCalcs.skillFlags) + end + addModStore(actor.modDB) + addModStore(actor.enemy.modDB) + addModStore(env.player.modDB) + addModStore(env.minion and env.minion.modDB) + addTable(actor.output) + addTable(env.player.output) + addTable(actor.breakdown) + return snapshots +end + +local function restoreOffenceState(snapshots) + for _, snapshot in ipairs(snapshots) do + wipeTable(snapshot.tbl) + for key, value in pairs(snapshot.copy) do + snapshot.tbl[key] = value + end + end +end + +-- Heavy Stun buildup at which an enemy of each rarity is Primed for Stun (PoB does not model Magic enemies) +local heavyStunPrimedThreshold = { Normal = 40, Magic = 50, Rare = 60, Unique = 70 } + +-- Estimates how many hits and how long it takes to Heavy Stun the enemy, and which share of the time it then spends +-- Heavy Stunned. Only the first cycle is modelled: the bar fills from zero, the enemy stays Heavy Stunned for the +-- Heavy Stun duration, and the bar resets. +local function calcHeavyStunCycle(env, actor, activeSkill) + local output = actor.output + local breakdown = actor.breakdown + local skillModList = activeSkill.skillModList + local enemyDB = actor.enemy.modDB + local skillFlags = env.mode == "CALCS" and activeSkill.activeEffect.statSetCalcs.skillFlags or activeSkill.activeEffect.statSet.skillFlags + local cfg = skillFlags.weapon1Attack and activeSkill.weapon1Cfg or activeSkill.skillCfg + local buildup = output.HeavyStunBuildupAvg or 0 + local quantityMultiplier = m_max(skillModList:Sum("BASE", activeSkill.skillCfg, "QuantityMultiplier"), 1) + local hitsPerSecond = (output.HitSpeed or output.Speed or 0) * (output.DpsMultiplier or 1) * quantityMultiplier * (output.HitChance or 100) / 100 + output.HeavyStunUptime = 0 + if skillFlags.disable or not skillFlags.hit or buildup <= 0 or hitsPerSecond <= 0 then + return + end + + local baseDuration = data.monsterConstants["base_heavy_stun_duration_ms"] / 1000 + local incDuration = skillModList:Sum("INC", cfg, "EnemyStunDuration") + local moreDuration = skillModList:More(cfg, "EnemyStunDuration") + local chanceToDouble = m_min(skillModList:Sum("BASE", cfg, "DoubleEnemyStunDurationChance") + enemyDB:Sum("BASE", nil, "SelfDoubleStunDurationChance"), 100) + local incRecovery = enemyDB:Sum("INC", nil, "StunRecovery") + output.HeavyStunDuration = baseDuration * (1 + incDuration / 100) * moreDuration * (1 + chanceToDouble / 100) / (1 + incRecovery / 100) + + local hitsToFill = m_ceil(100 / buildup) + output.HitsToHeavyStun = hitsToFill + local crushingBlows = skillModList:Flag(cfg, "CrushingBlows") + local enemyRarity = enemyDB:Flag(nil, "Condition:Unique") and "Unique" or (enemyDB:Flag(nil, "Condition:RareOrUnique") and "Rare" or "Normal") + local primedThreshold = heavyStunPrimedThreshold[enemyRarity] + local hitsToPrime = m_ceil(primedThreshold / buildup) + if crushingBlows then + -- The Crushing Blow after the hit that Primes the enemy causes the Heavy Stun + output.HitsToHeavyStun = m_min(hitsToFill, hitsToPrime + 1) + end + output.TimeToHeavyStun = output.HitsToHeavyStun / hitsPerSecond + output.HeavyStunUptime = output.HeavyStunDuration / (output.HeavyStunDuration + output.TimeToHeavyStun) * 100 + + if breakdown then + breakdown.HitsToHeavyStun = { + s_format("100%% / %.1f%% ^8(Heavy Stun buildup per hit)", buildup), + s_format("= %d ^8(hits to fill the Heavy Stun bar)", hitsToFill), + } + if crushingBlows then + t_insert(breakdown.HitsToHeavyStun, s_format("%d%% / %.1f%% ^8(%s enemies are Primed for Stun at %d%% buildup)", primedThreshold, buildup, enemyRarity, primedThreshold)) + t_insert(breakdown.HitsToHeavyStun, s_format("= %d + 1 ^8(hits to Prime for Stun, then a Crushing Blow)", hitsToPrime)) + t_insert(breakdown.HitsToHeavyStun, s_format("= %d ^8(the fewer of the two)", output.HitsToHeavyStun)) + end + breakdown.HeavyStunDuration = { s_format("%.2fs ^8(base Heavy Stun duration)", baseDuration) } + if incDuration ~= 0 then + t_insert(breakdown.HeavyStunDuration, s_format("x %.2f ^8(increased/reduced stun duration)", 1 + incDuration / 100)) + end + if moreDuration ~= 1 then + t_insert(breakdown.HeavyStunDuration, s_format("x %.2f ^8(more/less stun duration)", moreDuration)) + end + if chanceToDouble ~= 0 then + t_insert(breakdown.HeavyStunDuration, s_format("x %.2f ^8(chance to double stun duration)", 1 + chanceToDouble / 100)) + end + if incRecovery ~= 0 then + t_insert(breakdown.HeavyStunDuration, s_format("/ %.2f ^8(increased/reduced enemy stun recovery)", 1 + incRecovery / 100)) + end + t_insert(breakdown.HeavyStunDuration, s_format("= %.2fs", output.HeavyStunDuration)) + breakdown.HeavyStunUptime = { + s_format("%d ^8(hits to Heavy Stun)", output.HitsToHeavyStun), + s_format("/ %.2f ^8(hits per second)", hitsPerSecond), + s_format("= %.2fs ^8(time to Heavy Stun)", output.TimeToHeavyStun), + s_format("%.2fs / (%.2fs + %.2fs) ^8(Heavy Stun duration / (Heavy Stun duration + time to Heavy Stun))", output.HeavyStunDuration, output.HeavyStunDuration, output.TimeToHeavyStun), + s_format("= %.1f%%", output.HeavyStunUptime), + } + end +end + +-- The Heavy Stun estimate uses hits against an enemy that is not Heavy Stunned yet +local heavyStunUnblendedStats = { HeavyStunBuildupAvg = true, HeavyStunDuration = true, HitsToHeavyStun = true, TimeToHeavyStun = true, HeavyStunUptime = true } + +local function blendHeavyStunOutput(output, stunnedOutput, uptime) + for stat, value in pairs(output) do + local stunnedValue = stunnedOutput[stat] + if type(value) == "number" and type(stunnedValue) == "number" and stunnedValue ~= value and not heavyStunUnblendedStats[stat] then + output[stat] = value + (stunnedValue - value) * uptime + elseif (stat == "MainHand" or stat == "OffHand") and type(value) == "table" and type(stunnedValue) == "table" then + blendHeavyStunOutput(value, stunnedValue, uptime) + end + end +end + +-- Performs all offensive calculations +-- When the enemy is Heavy Stunned for only part of the time, the calculations are run against a Heavy Stunned and a normal +-- enemy, and the results are averaged by the share of the time the enemy spends Heavy Stunned +---@param env Env +---@param actor Actor +---@param activeSkill ActiveSkill +function calcs.offence(env, actor, activeSkill) + local mode = env.configInput.enemyHeavyStunMode + if mode ~= "CUSTOM" and mode ~= "CALCULATED" then + calcOffence(env, actor, activeSkill) + if mode ~= "ALWAYS" then + -- Only hits against an enemy that is not Heavy Stunned yet show how long it takes to Heavy Stun it + calcHeavyStunCycle(env, actor, activeSkill) + end + return + end + + local enemyDB = actor.enemy.modDB + local snapshots = snapshotOffenceState(env, actor, activeSkill) + for _, condition in ipairs({ "HeavyStunned", "Stunned", "Immobilised" }) do + enemyDB:NewMod("Condition:"..condition, "FLAG", true, "Heavy Stun uptime", { type = "Condition", var = "Effective" }) + end + calcOffence(env, actor, activeSkill) + local stunnedOutput = copyTable(actor.output, true) + restoreOffenceState(snapshots) + + calcOffence(env, actor, activeSkill) + calcHeavyStunCycle(env, actor, activeSkill) + local output = actor.output + local uptime + if mode == "CUSTOM" then + uptime = m_min(m_max(enemyDB:Sum("BASE", nil, "HeavyStunUptime"), 0), 100) + else + uptime = output.HeavyStunUptime + end + local normalDPS = output.CombinedDPS + blendHeavyStunOutput(output, stunnedOutput, uptime / 100) + + local breakdown = actor.breakdown + if breakdown then + breakdown.HeavyStunUptime = breakdown.HeavyStunUptime or { } + t_insert(breakdown.HeavyStunUptime, "") + t_insert(breakdown.HeavyStunUptime, s_format("DPS averaged over %.1f%% Heavy Stun uptime%s:", uptime, mode == "CUSTOM" and " ^8(from the Configuration tab)" or "")) + t_insert(breakdown.HeavyStunUptime, s_format("%.1f ^8(combined DPS against a Heavy Stunned enemy)", stunnedOutput.CombinedDPS or 0)) + t_insert(breakdown.HeavyStunUptime, s_format("%.1f ^8(combined DPS against an enemy that is not Heavy Stunned)", normalDPS or 0)) + t_insert(breakdown.HeavyStunUptime, s_format("= %.1f", output.CombinedDPS or 0)) + end +end diff --git a/src/Modules/CalcSections.lua b/src/Modules/CalcSections.lua index 787ffca96b..248bc734ff 100644 --- a/src/Modules/CalcSections.lua +++ b/src/Modules/CalcSections.lua @@ -1508,6 +1508,18 @@ return { { label = "Player modifiers", modName = { "EnemyHeavyStunBuildup", "EnemyImmobilisationBuildup", "PhysicalHeavyStunBuildup" }, cfg = "skill" }, { label = "Enemy modifiers", modName = { "PoiseThreshold", "EnemyHeavyStunThreshold", "EnemyStunThreshold", "HeavyStunBuildup", "ImmobilisationBuildup", "StunBuildup" }, enemy = true }, }, }, + { label = "Hits to Heavy Stun", haveOutput = "HitsToHeavyStun", { format = "{0:output:HitsToHeavyStun}", + { breakdown = "HitsToHeavyStun" }, + { label = "Player modifiers", modName = { "CrushingBlows" }, cfg = "skill" }, + }, }, + { label = "Heavy Stun Duration", haveOutput = "HeavyStunDuration", { format = "{2:output:HeavyStunDuration}s", + { breakdown = "HeavyStunDuration" }, + { label = "Player modifiers", modName = { "EnemyStunDuration", "DoubleEnemyStunDurationChance" }, cfg = "skill" }, + { label = "Enemy modifiers", modName = { "StunRecovery", "SelfDoubleStunDurationChance" }, enemy = true }, + }, }, + { label = "Heavy Stun Uptime", haveOutput = "HeavyStunUptime", { format = "{1:output:HeavyStunUptime}%", + { breakdown = "HeavyStunUptime" }, + }, }, { label = "Pin Buildup", haveOutput = "PinBuildupAvg", { format = "{1:output:PinBuildupAvg}%", { breakdown = "PinBuildup" }, { label = "Player modifiers", modName = { "EnemyPinBuildup", "EnemyImmobilisationBuildup" }, cfg = "skill" }, diff --git a/src/Modules/ConfigOptions.lua b/src/Modules/ConfigOptions.lua index 9033f6a152..53f3c0c659 100644 --- a/src/Modules/ConfigOptions.lua +++ b/src/Modules/ConfigOptions.lua @@ -1705,10 +1705,15 @@ Huge sets the radius to 11. { var = "conditionEnemyStunned", type = "check", label = "Is the enemy Stunned?", ifEnemyCond = "Stunned", apply = function(val, modList, enemyModList) enemyModList:NewMod("Condition:Stunned", "FLAG", true, "Config", { type = "Condition", var = "Effective" }) end }, - { var = "conditionEnemyHeavyStunned", type = "check", label = "Is the enemy Heavy Stunned?", ifEnemyCond = "HeavyStunned", tooltip = "This also implies that the enemy is stunned.", apply = function(val, modList, enemyModList) - enemyModList:NewMod("Condition:HeavyStunned", "FLAG", true, "Config", { type = "Condition", var = "Effective" }) - enemyModList:NewMod("Condition:Stunned", "FLAG", true, "Config", { type = "Condition", var = "Effective" }) - enemyModList:NewMod("Condition:Immobilised", "FLAG", true, "Config", { type = "Condition", var = "Effective" }) + { var = "enemyHeavyStunMode", type = "list", label = "Is the enemy Heavy Stunned?", ifEnemyCond = "HeavyStunned", tooltip = "Controls how often the enemy is Heavy Stunned:\n\tNo: never\n\tAlways: all the time, which also implies that the enemy is Stunned and Immobilised\n\tCustom %: for the entered share of the time\n\tCalculated: for the share of the time estimated from the skill's Stun buildup per hit, hits per second and Heavy Stun duration (2s base)\nWith Custom % and Calculated, damage is averaged between hits against a Heavy Stunned and a normal enemy.\nWith Crushing Blows, the hit after the one that Primes the enemy for Stun causes the Heavy Stun (Primed at 40% buildup for normal, 60% for rare and 70% for unique enemies).\nOnly the first Heavy Stun is modelled: the enemy's higher Stun threshold after being Heavy Stunned is ignored.", list = {{val="NONE",label="No"},{val="ALWAYS",label="Always"},{val="CUSTOM",label="Custom %"},{val="CALCULATED",label="Calculated"}}, apply = function(val, modList, enemyModList) + if val == "ALWAYS" then + enemyModList:NewMod("Condition:HeavyStunned", "FLAG", true, "Config", { type = "Condition", var = "Effective" }) + enemyModList:NewMod("Condition:Stunned", "FLAG", true, "Config", { type = "Condition", var = "Effective" }) + enemyModList:NewMod("Condition:Immobilised", "FLAG", true, "Config", { type = "Condition", var = "Effective" }) + end + end }, + { var = "enemyHeavyStunUptime", type = "countAllowZero", label = "Heavy Stun uptime %:", ifOption = "enemyHeavyStunMode", ifOptionValue = "CUSTOM", ifEnemyCond = "HeavyStunned", defaultPlaceholderState = 50, tooltip = "Share of the time the enemy spends Heavy Stunned.", apply = function(val, modList, enemyModList) + enemyModList:NewMod("HeavyStunUptime", "BASE", m_max(m_min(val, 100), 0), "Config") end }, { var = "conditionEnemyBleeding", type = "check", label = "Is the enemy Bleeding?", ifEnemyCond = "Bleeding", apply = function(val, modList, enemyModList) enemyModList:NewMod("Condition:Bleeding", "FLAG", true, "Config", { type = "Condition", var = "Effective" }) diff --git a/src/Modules/ModParser.lua b/src/Modules/ModParser.lua index ff8a00f8fa..80e535a28e 100644 --- a/src/Modules/ModParser.lua +++ b/src/Modules/ModParser.lua @@ -5883,6 +5883,7 @@ local specialModList = { ["your hits can't be evaded by blinded enemies"] = { flag("CannotBeEvaded", { type = "ActorCondition", actor = "enemy", var = "Blinded" }) }, ["your hits cannot be evaded by pinned enemies"] = { flag("CannotBeEvaded", { type = "ActorCondition", actor = "enemy", var = "Pinned" }) }, ["your hits cannot be evaded by heavy stunned enemies"] = { flag("CannotBeEvaded", { type = "ActorCondition", actor = "enemy", var = "HeavyStunned" }) }, + ["your hits are crushing blows"] = { flag("CrushingBlows") }, ["cannot immobilise enemies"] = { flag("CannotElectrocute"), flag("CannotFreeze"), From 05f863e790d1383219d5643de85c5ad3c0eb4265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=A0i=C5=A1ka?= Date: Fri, 2 Oct 2026 08:16:45 +0200 Subject: [PATCH 2/2] Pick the Heavy Stun uptime from a single dropdown Replace the No/Always/Custom %/Calculated list and its separate uptime field with one "Enemy Heavy Stun uptime" dropdown: None, 25%, 50%, 75%, 100% and Calculated. 100% behaves like the old Always, 25-75% average damage like the old Custom %, and the old Heavy Stunned checkbox loads as 100%. This drops the ifOptionValue ConfigTab key the uptime field needed. Co-Authored-By: Claude Opus 5.5 --- spec/System/TestHeavyStun_spec.lua | 76 ++++++++++++------------------ src/Classes/ConfigTab.lua | 10 ++-- src/Modules/CalcOffence.lua | 16 +++---- src/Modules/ConfigOptions.lua | 7 +-- 4 files changed, 42 insertions(+), 67 deletions(-) diff --git a/spec/System/TestHeavyStun_spec.lua b/spec/System/TestHeavyStun_spec.lua index 9d337e40eb..9443725ea0 100644 --- a/spec/System/TestHeavyStun_spec.lua +++ b/spec/System/TestHeavyStun_spec.lua @@ -54,32 +54,29 @@ describe("TestHeavyStun", function() end) describe("configuration", function() - it("loads the old Heavy Stunned checkbox as Always", function() + it("loads the old Heavy Stunned checkbox as 100% uptime", function() build.configTab:Load({ attrib = { }, { elem = "Input", attrib = { name = "conditionEnemyHeavyStunned", boolean = "true" } } }, "test") - assert.are.equals("ALWAYS", build.configTab.configSets[1].input.enemyHeavyStunMode) + assert.are.equals("100", build.configTab.configSets[1].input.enemyHeavyStunUptime) assert.is_nil(build.configTab.configSets[1].input.conditionEnemyHeavyStunned) end) - it("shows the uptime field only for Custom %", function() - setupMaceStrike() - local stunMod = "100% more Damage against Heavy Stunned Enemies" - local control = build.configTab.varControls.enemyHeavyStunUptime - - setConfig({ enemyHeavyStunMode = "CALCULATED" }, stunMod) - assert.is_false(control.shown()) + it("offers None, fixed uptimes and Calculated in one list", function() + local labels = { } + for _, item in ipairs(build.configTab.varControls.enemyHeavyStunUptime.list) do + labels[#labels + 1] = item.label + end - setConfig({ enemyHeavyStunMode = "CUSTOM" }, stunMod) - assert.is_true(control.shown()) + assert.are.same({ "None", "25%", "50%", "75%", "100%", "Calculated" }, labels) end) - it("treats the enemy as Heavy Stunned all the time for Always", function() + it("treats the enemy as Heavy Stunned all the time at 100%", function() setupMaceStrike() local stunMod = "100% more Damage against Heavy Stunned Enemies" - setConfig({ enemyHeavyStunMode = "NONE" }, stunMod) + setConfig({ enemyHeavyStunUptime = "NONE" }, stunMod) local notStunnedHit = build.calcsTab.mainOutput.AverageDamage - setConfig({ enemyHeavyStunMode = "ALWAYS" }, stunMod) + setConfig({ enemyHeavyStunUptime = "100" }, stunMod) assert.are.equals(round(notStunnedHit * 2, 4), round(build.calcsTab.mainOutput.AverageDamage, 4)) end) @@ -111,40 +108,29 @@ describe("TestHeavyStun", function() end end - it("averages damage against a Heavy Stunned and a normal enemy by the custom uptime", function() + it("averages damage against a Heavy Stunned and a normal enemy by the selected uptime", function() setupMaceStrike() - setConfig({ enemyHeavyStunMode = "NONE" }, stunMod) + setConfig({ enemyHeavyStunUptime = "NONE" }, stunMod) local normalDPS = build.calcsTab.mainOutput.CombinedDPS - setConfig({ enemyHeavyStunMode = "ALWAYS" }, stunMod) + setConfig({ enemyHeavyStunUptime = "100" }, stunMod) local stunnedDPS = build.calcsTab.mainOutput.CombinedDPS assert.is_true(stunnedDPS > normalDPS) - setConfig({ enemyHeavyStunMode = "CUSTOM", enemyHeavyStunUptime = 40 }, stunMod) - - assert.are.equals(round(0.4 * stunnedDPS + 0.6 * normalDPS, 6), round(build.calcsTab.mainOutput.CombinedDPS, 6)) - end) + for _, uptime in ipairs({ 25, 50, 75 }) do + setConfig({ enemyHeavyStunUptime = tostring(uptime) }, stunMod) - it("matches No at 0% and Always at 100% custom uptime", function() - setupMaceStrike() - setConfig({ enemyHeavyStunMode = "NONE" }, stunMod) - local normal = numericOutputs(build.calcsTab.mainOutput) - setConfig({ enemyHeavyStunMode = "ALWAYS" }, stunMod) - local stunned = numericOutputs(build.calcsTab.mainOutput) - - setConfig({ enemyHeavyStunMode = "CUSTOM", enemyHeavyStunUptime = 0 }, stunMod) - assertSameOutputs(normal, numericOutputs(build.calcsTab.mainOutput)) - setConfig({ enemyHeavyStunMode = "CUSTOM", enemyHeavyStunUptime = 100 }, stunMod) - assert.are.equals(round(stunned.CombinedDPS, 6), round(build.calcsTab.mainOutput.CombinedDPS, 6)) + assert.are.equals(round(normalDPS + (stunnedDPS - normalDPS) * uptime / 100, 6), round(build.calcsTab.mainOutput.CombinedDPS, 6)) + end end) it("does not apply offence mods twice when blending", function() equipMace() build.skillsTab:PasteSocketGroup("skillId:Melee2HMacePlayer Mace Strike 20/0 1\nRuthless 1/0 1") build.mainSocketGroup = 1 - setConfig({ enemyHeavyStunMode = "NONE" }) + setConfig({ enemyHeavyStunUptime = "NONE" }) local normal = numericOutputs(build.calcsTab.mainOutput) - setConfig({ enemyHeavyStunMode = "CUSTOM", enemyHeavyStunUptime = 50 }) + setConfig({ enemyHeavyStunUptime = "50" }) assertSameOutputs(normal, numericOutputs(build.calcsTab.mainOutput)) end) @@ -152,10 +138,10 @@ describe("TestHeavyStun", function() it("does not apply spell offence mods twice when blending", function() build.skillsTab:PasteSocketGroup("Fireball 20/0 1") build.mainSocketGroup = 1 - setConfig({ enemyHeavyStunMode = "NONE" }) + setConfig({ enemyHeavyStunUptime = "NONE" }) local normal = numericOutputs(build.calcsTab.mainOutput) - setConfig({ enemyHeavyStunMode = "CUSTOM", enemyHeavyStunUptime = 50 }) + setConfig({ enemyHeavyStunUptime = "50" }) assertSameOutputs(normal, numericOutputs(build.calcsTab.mainOutput)) end) @@ -163,10 +149,10 @@ describe("TestHeavyStun", function() it("does not apply minion offence mods twice when blending", function() build.skillsTab:PasteSocketGroup("Skeletal Sniper 20/0 1") build.mainSocketGroup = 1 - setConfig({ enemyHeavyStunMode = "NONE" }) + setConfig({ enemyHeavyStunUptime = "NONE" }) local normal = numericOutputs(build.calcsTab.mainEnv.minion.output) - setConfig({ enemyHeavyStunMode = "CUSTOM", enemyHeavyStunUptime = 50 }) + setConfig({ enemyHeavyStunUptime = "50" }) assertSameOutputs(normal, numericOutputs(build.calcsTab.mainEnv.minion.output)) end) @@ -174,14 +160,14 @@ describe("TestHeavyStun", function() it("averages damage by the calculated uptime", function() setupMaceStrike() local enemy = { enemyIsBoss = "None", enemyLevel = 20 } - enemy.enemyHeavyStunMode = "NONE" + enemy.enemyHeavyStunUptime = "NONE" setConfig(enemy, stunMod) local normalDPS = build.calcsTab.mainOutput.CombinedDPS - enemy.enemyHeavyStunMode = "ALWAYS" + enemy.enemyHeavyStunUptime = "100" setConfig(enemy, stunMod) local stunnedDPS = build.calcsTab.mainOutput.CombinedDPS - enemy.enemyHeavyStunMode = "CALCULATED" + enemy.enemyHeavyStunUptime = "CALCULATED" setConfig(enemy, stunMod) local output = build.calcsTab.mainOutput @@ -191,7 +177,7 @@ describe("TestHeavyStun", function() it("leaves the enemy not Heavy Stunned after blending", function() setupMaceStrike() - setConfig({ enemyHeavyStunMode = "CUSTOM", enemyHeavyStunUptime = 50 }, stunMod) + setConfig({ enemyHeavyStunUptime = "50" }, stunMod) assert.is_true(not build.calcsTab.mainEnv.enemy.modDB:Flag(nil, "Condition:HeavyStunned")) end) @@ -202,7 +188,7 @@ describe("TestHeavyStun", function() local baseMods = "Adds 100 to 100 Physical Damage to Attacks" local function calculate(customMods, enemy) - local input = { enemyHeavyStunMode = "CALCULATED", enemyIsBoss = "None", enemyLevel = 20, conditionEnemyRareOrUnique = false } + local input = { enemyHeavyStunUptime = "CALCULATED", enemyIsBoss = "None", enemyLevel = 20, conditionEnemyRareOrUnique = false } for var, val in pairs(enemy or { }) do input[var] = val end @@ -287,9 +273,9 @@ describe("TestHeavyStun", function() end) it("estimates the uptime when the enemy is not Heavy Stunned, but not when it always is", function() - assert.is_true(calculate(nil, { enemyHeavyStunMode = "NONE" }).HeavyStunUptime > 0) + assert.is_true(calculate(nil, { enemyHeavyStunUptime = "NONE" }).HeavyStunUptime > 0) - assert.is_nil(calculate(nil, { enemyHeavyStunMode = "ALWAYS" }).HitsToHeavyStun) + assert.is_nil(calculate(nil, { enemyHeavyStunUptime = "100" }).HitsToHeavyStun) end) it("explains the uptime in the Calcs tab", function() diff --git a/src/Classes/ConfigTab.lua b/src/Classes/ConfigTab.lua index 1f7b131c5d..5686d9b431 100644 --- a/src/Classes/ConfigTab.lua +++ b/src/Classes/ConfigTab.lua @@ -405,11 +405,7 @@ function ConfigTabClass:ConfigTab(build) end if varData.ifOption then t_insert(shownFuncs, listOrSingleIfOption(varData.ifOption, function(ifOption) - local value = self.configSets[self.activeConfigSetId].input[ifOption] - if varData.ifOptionValue ~= nil then - return value == varData.ifOptionValue - end - return value + return self.configSets[self.activeConfigSetId].input[ifOption] end)) end if varData.ifCond then @@ -903,10 +899,10 @@ function ConfigTabClass:Load(xml, fileName) self.configSets[configSetId].input[node.attrib.name] = node.attrib.string end elseif node.attrib.boolean then - -- backwards compat: the Heavy Stunned checkbox became the Heavy Stun uptime mode list + -- backwards compat: the Heavy Stunned checkbox became the Heavy Stun uptime list if node.attrib.name == "conditionEnemyHeavyStunned" then if node.attrib.boolean == "true" then - self.configSets[configSetId].input.enemyHeavyStunMode = "ALWAYS" + self.configSets[configSetId].input.enemyHeavyStunUptime = "100" end else self.configSets[configSetId].input[node.attrib.name] = node.attrib.boolean == "true" diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 648e59a9bd..1e41e0ef61 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -6621,10 +6621,11 @@ end ---@param actor Actor ---@param activeSkill ActiveSkill function calcs.offence(env, actor, activeSkill) - local mode = env.configInput.enemyHeavyStunMode - if mode ~= "CUSTOM" and mode ~= "CALCULATED" then + local uptimeSetting = env.configInput.enemyHeavyStunUptime + local selectedUptime = tonumber(uptimeSetting) + if uptimeSetting ~= "CALCULATED" and not (selectedUptime and selectedUptime > 0 and selectedUptime < 100) then calcOffence(env, actor, activeSkill) - if mode ~= "ALWAYS" then + if selectedUptime ~= 100 then -- Only hits against an enemy that is not Heavy Stunned yet show how long it takes to Heavy Stun it calcHeavyStunCycle(env, actor, activeSkill) end @@ -6643,12 +6644,7 @@ function calcs.offence(env, actor, activeSkill) calcOffence(env, actor, activeSkill) calcHeavyStunCycle(env, actor, activeSkill) local output = actor.output - local uptime - if mode == "CUSTOM" then - uptime = m_min(m_max(enemyDB:Sum("BASE", nil, "HeavyStunUptime"), 0), 100) - else - uptime = output.HeavyStunUptime - end + local uptime = selectedUptime or output.HeavyStunUptime local normalDPS = output.CombinedDPS blendHeavyStunOutput(output, stunnedOutput, uptime / 100) @@ -6656,7 +6652,7 @@ function calcs.offence(env, actor, activeSkill) if breakdown then breakdown.HeavyStunUptime = breakdown.HeavyStunUptime or { } t_insert(breakdown.HeavyStunUptime, "") - t_insert(breakdown.HeavyStunUptime, s_format("DPS averaged over %.1f%% Heavy Stun uptime%s:", uptime, mode == "CUSTOM" and " ^8(from the Configuration tab)" or "")) + t_insert(breakdown.HeavyStunUptime, s_format("DPS averaged over %.1f%% Heavy Stun uptime%s:", uptime, selectedUptime and " ^8(from the Configuration tab)" or "")) t_insert(breakdown.HeavyStunUptime, s_format("%.1f ^8(combined DPS against a Heavy Stunned enemy)", stunnedOutput.CombinedDPS or 0)) t_insert(breakdown.HeavyStunUptime, s_format("%.1f ^8(combined DPS against an enemy that is not Heavy Stunned)", normalDPS or 0)) t_insert(breakdown.HeavyStunUptime, s_format("= %.1f", output.CombinedDPS or 0)) diff --git a/src/Modules/ConfigOptions.lua b/src/Modules/ConfigOptions.lua index 53f3c0c659..92c8754a51 100644 --- a/src/Modules/ConfigOptions.lua +++ b/src/Modules/ConfigOptions.lua @@ -1705,16 +1705,13 @@ Huge sets the radius to 11. { var = "conditionEnemyStunned", type = "check", label = "Is the enemy Stunned?", ifEnemyCond = "Stunned", apply = function(val, modList, enemyModList) enemyModList:NewMod("Condition:Stunned", "FLAG", true, "Config", { type = "Condition", var = "Effective" }) end }, - { var = "enemyHeavyStunMode", type = "list", label = "Is the enemy Heavy Stunned?", ifEnemyCond = "HeavyStunned", tooltip = "Controls how often the enemy is Heavy Stunned:\n\tNo: never\n\tAlways: all the time, which also implies that the enemy is Stunned and Immobilised\n\tCustom %: for the entered share of the time\n\tCalculated: for the share of the time estimated from the skill's Stun buildup per hit, hits per second and Heavy Stun duration (2s base)\nWith Custom % and Calculated, damage is averaged between hits against a Heavy Stunned and a normal enemy.\nWith Crushing Blows, the hit after the one that Primes the enemy for Stun causes the Heavy Stun (Primed at 40% buildup for normal, 60% for rare and 70% for unique enemies).\nOnly the first Heavy Stun is modelled: the enemy's higher Stun threshold after being Heavy Stunned is ignored.", list = {{val="NONE",label="No"},{val="ALWAYS",label="Always"},{val="CUSTOM",label="Custom %"},{val="CALCULATED",label="Calculated"}}, apply = function(val, modList, enemyModList) - if val == "ALWAYS" then + { var = "enemyHeavyStunUptime", type = "list", label = "Enemy Heavy Stun uptime:", ifEnemyCond = "HeavyStunned", tooltip = "Share of the time the enemy spends Heavy Stunned:\n\tNone: never\n\t25% to 75%: damage is averaged between hits against a Heavy Stunned and a normal enemy\n\t100%: all the time, which also implies that the enemy is Stunned and Immobilised\n\tCalculated: estimated from the skill's Stun buildup per hit, hits per second and Heavy Stun duration (2s base), then averaged like 25% to 75%\nWith Crushing Blows, the hit after the one that Primes the enemy for Stun causes the Heavy Stun (Primed at 40% buildup for normal, 60% for rare and 70% for unique enemies).\nOnly the first Heavy Stun is modelled: the enemy's higher Stun threshold after being Heavy Stunned is ignored.", list = {{val="NONE",label="None"},{val="25",label="25%"},{val="50",label="50%"},{val="75",label="75%"},{val="100",label="100%"},{val="CALCULATED",label="Calculated"}}, apply = function(val, modList, enemyModList) + if val == "100" then enemyModList:NewMod("Condition:HeavyStunned", "FLAG", true, "Config", { type = "Condition", var = "Effective" }) enemyModList:NewMod("Condition:Stunned", "FLAG", true, "Config", { type = "Condition", var = "Effective" }) enemyModList:NewMod("Condition:Immobilised", "FLAG", true, "Config", { type = "Condition", var = "Effective" }) end end }, - { var = "enemyHeavyStunUptime", type = "countAllowZero", label = "Heavy Stun uptime %:", ifOption = "enemyHeavyStunMode", ifOptionValue = "CUSTOM", ifEnemyCond = "HeavyStunned", defaultPlaceholderState = 50, tooltip = "Share of the time the enemy spends Heavy Stunned.", apply = function(val, modList, enemyModList) - enemyModList:NewMod("HeavyStunUptime", "BASE", m_max(m_min(val, 100), 0), "Config") - end }, { var = "conditionEnemyBleeding", type = "check", label = "Is the enemy Bleeding?", ifEnemyCond = "Bleeding", apply = function(val, modList, enemyModList) enemyModList:NewMod("Condition:Bleeding", "FLAG", true, "Config", { type = "Condition", var = "Effective" }) end },