diff --git a/spec/System/TestHeavyStun_spec.lua b/spec/System/TestHeavyStun_spec.lua new file mode 100644 index 0000000000..9443725ea0 --- /dev/null +++ b/spec/System/TestHeavyStun_spec.lua @@ -0,0 +1,290 @@ +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 100% uptime", function() + build.configTab:Load({ attrib = { }, { elem = "Input", attrib = { name = "conditionEnemyHeavyStunned", boolean = "true" } } }, "test") + + assert.are.equals("100", build.configTab.configSets[1].input.enemyHeavyStunUptime) + assert.is_nil(build.configTab.configSets[1].input.conditionEnemyHeavyStunned) + end) + + 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 + + assert.are.same({ "None", "25%", "50%", "75%", "100%", "Calculated" }, labels) + end) + + it("treats the enemy as Heavy Stunned all the time at 100%", function() + setupMaceStrike() + local stunMod = "100% more Damage against Heavy Stunned Enemies" + + setConfig({ enemyHeavyStunUptime = "NONE" }, stunMod) + local notStunnedHit = build.calcsTab.mainOutput.AverageDamage + setConfig({ enemyHeavyStunUptime = "100" }, 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 selected uptime", function() + setupMaceStrike() + setConfig({ enemyHeavyStunUptime = "NONE" }, stunMod) + local normalDPS = build.calcsTab.mainOutput.CombinedDPS + setConfig({ enemyHeavyStunUptime = "100" }, stunMod) + local stunnedDPS = build.calcsTab.mainOutput.CombinedDPS + assert.is_true(stunnedDPS > normalDPS) + + for _, uptime in ipairs({ 25, 50, 75 }) do + setConfig({ enemyHeavyStunUptime = tostring(uptime) }, stunMod) + + 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({ enemyHeavyStunUptime = "NONE" }) + local normal = numericOutputs(build.calcsTab.mainOutput) + + setConfig({ 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({ enemyHeavyStunUptime = "NONE" }) + local normal = numericOutputs(build.calcsTab.mainOutput) + + setConfig({ 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({ enemyHeavyStunUptime = "NONE" }) + local normal = numericOutputs(build.calcsTab.mainEnv.minion.output) + + setConfig({ 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.enemyHeavyStunUptime = "NONE" + setConfig(enemy, stunMod) + local normalDPS = build.calcsTab.mainOutput.CombinedDPS + enemy.enemyHeavyStunUptime = "100" + setConfig(enemy, stunMod) + local stunnedDPS = build.calcsTab.mainOutput.CombinedDPS + + enemy.enemyHeavyStunUptime = "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({ 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 = { enemyHeavyStunUptime = "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, { enemyHeavyStunUptime = "NONE" }).HeavyStunUptime > 0) + + assert.is_nil(calculate(nil, { enemyHeavyStunUptime = "100" }).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..5686d9b431 100644 --- a/src/Classes/ConfigTab.lua +++ b/src/Classes/ConfigTab.lua @@ -899,7 +899,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 list + if node.attrib.name == "conditionEnemyHeavyStunned" then + if node.attrib.boolean == "true" then + self.configSets[configSetId].input.enemyHeavyStunUptime = "100" + 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..1e41e0ef61 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,192 @@ 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 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 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 + 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 = selectedUptime or output.HeavyStunUptime + 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, 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)) + 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..92c8754a51 100644 --- a/src/Modules/ConfigOptions.lua +++ b/src/Modules/ConfigOptions.lua @@ -1705,10 +1705,12 @@ 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 = "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 = "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"),