From f7d966a0eb92b09aafc5797176dafa588137804a Mon Sep 17 00:00:00 2001 From: Dimencia Date: Wed, 31 Dec 2025 13:26:11 -0500 Subject: [PATCH 1/6] Add handling for forced outcome --- src/Data/ModCache.lua | 2 +- src/Modules/CalcOffence.lua | 22 ++++++++++++++++++++++ src/Modules/ModParser.lua | 1 + 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Data/ModCache.lua b/src/Data/ModCache.lua index bea812bcb..2da06ea1c 100644 --- a/src/Data/ModCache.lua +++ b/src/Data/ModCache.lua @@ -5373,7 +5373,7 @@ c["Increases and Reductions to Minion Attack Speed also affect you"]={{[1]={flag c["Increases and Reductions to Minion Damage also affect you"]={{[1]={flags=0,keywordFlags=0,name="MinionDamageAppliesToPlayer",type="FLAG",value=true},[2]={flags=0,keywordFlags=0,name="ImprovedMinionDamageAppliesToPlayer",type="MAX",value=100}},nil} c["Increases and Reductions to Projectile Speed also apply to Damage with Bows"]={{[1]={flags=0,keywordFlags=0,name="ProjectileSpeedAppliesToBowDamage",type="FLAG",value=true}},nil} c["Increases and Reductions to Spell damage also apply to Attacks"]={{[1]={flags=0,keywordFlags=0,name="SpellDamageAppliesToAttacks",type="FLAG",value=true},[2]={flags=0,keywordFlags=0,name="ImprovedSpellDamageAppliesToAttacks",type="MAX",value=100}},nil} -c["Inevitable Critical Hits"]={nil,"Inevitable Critical Hits "} +c["Inevitable Critical Hits"]={{[1]={flags=0,keywordFlags=0,name="InevitableCriticalHits",type="FLAG",value=true}},nil} c["Infinite Parry Range"]={nil,"Infinite Parry Range "} c["Infinite Parry Range 50% increased Parried Debuff Duration"]={nil,"Infinite Parry Range 50% increased Parried Debuff Duration "} c["Inflict Abyssal Wasting on Hit"]={nil,"Inflict Abyssal Wasting on Hit "} diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index eeab60788..940c2084b 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -3685,6 +3685,28 @@ function calcs.offence(env, actor, activeSkill) if pass == 1 then -- Apply crit multiplier allMult = allMult * output.CritMultiplier + elseif activeSkill.skillModList:Flag(nil, "InevitableCriticalHits") then + -- Calculate average number of rerolls for a non-crit + -- Use pre-effective so we don't consider accuracy, which already scales DPS + local avgNumRerolls = 100 / output.PreEffectiveCritChance - 1 + local critBonusMultiplier = 1 - m_min(1, .3 * avgNumRerolls) + + -- Crit multiplier includes the base 100%, +some% bonus + -- but this penalty only applies to the some% bonus + local bonusMult = output.CritMultiplier - 1 + local modifiedBonus = bonusMult * critBonusMultiplier + local newCritMult = 1 + modifiedBonus + allMult = allMult * newCritMult + + if breakdown then + t_insert(breakdown[damageType], "") + t_insert(breakdown[damageType], "Inevitable Criticals: ") + t_insert(breakdown[damageType], s_format(" Base Crit Bonus: +%.2f%%", bonusMult * 100)) + t_insert(breakdown[damageType], s_format(" Avg Num Rerolls: %.2f", avgNumRerolls)) + t_insert(breakdown[damageType], s_format(" Avg Crit Bonus: +%.2f%%", modifiedBonus * 100)) + t_insert(breakdown[damageType], s_format("x %.2f ^8(Inevitable Crit Multiplier)", newCritMult)) + t_insert(breakdown[damageType], "") + end end damageTypeHitMin = damageTypeHitMin * allMult damageTypeHitMax = damageTypeHitMax * allMult diff --git a/src/Modules/ModParser.lua b/src/Modules/ModParser.lua index 9c5ca0e15..50b3233c5 100644 --- a/src/Modules/ModParser.lua +++ b/src/Modules/ModParser.lua @@ -3305,6 +3305,7 @@ local specialModList = { mod("EnemyModifier", "LIST", { mod = mod("LightningExposure", "BASE", -20) }, { type = "ActorCondition", actor = "enemy", var = "EnemyInPresence" }), }, -- Druid -- Oracle + ["inevitable critical hits"] = { flag("InevitableCriticalHits") }, ["walk the paths not taken"] = {}, ["gain the benefits of bonded modifiers on runes and idols"] = { flag("Condition:CanUseBondedModifiers"), From ca2c6766406aa34accc1f00e5c7dde71b417b322 Mon Sep 17 00:00:00 2001 From: Dimencia Date: Wed, 31 Dec 2025 13:36:13 -0500 Subject: [PATCH 2/6] Consider lucky crits --- src/Modules/CalcOffence.lua | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 940c2084b..0822a6e7e 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -3688,7 +3688,15 @@ function calcs.offence(env, actor, activeSkill) elseif activeSkill.skillModList:Flag(nil, "InevitableCriticalHits") then -- Calculate average number of rerolls for a non-crit -- Use pre-effective so we don't consider accuracy, which already scales DPS - local avgNumRerolls = 100 / output.PreEffectiveCritChance - 1 + local critChance = output.PreEffectiveCritChance + + -- Consider lucky crits because they were only applied post-effective + -- (not that they exist in POE2 for now, but just in case) + if skillModList:Flag(cfg, "CritChanceLucky") then + critChance = (1 - (1 - critChance / 100) ^ 2) * 100 + end + + local avgNumRerolls = 100 / critChance - 1 local critBonusMultiplier = 1 - m_min(1, .3 * avgNumRerolls) -- Crit multiplier includes the base 100%, +some% bonus From 0571620c04420ae71e9723de0fa96859b4d2f324 Mon Sep 17 00:00:00 2001 From: Dimencia Date: Sat, 10 Jan 2026 19:38:42 -0500 Subject: [PATCH 3/6] Rename to forced outcome to avoid ambiguation with support gem, fix calculations, move calculations --- src/Data/ModCache.lua | 2 +- src/Modules/CalcOffence.lua | 67 ++++++++++++++++++++----------------- src/Modules/ModParser.lua | 2 +- 3 files changed, 39 insertions(+), 32 deletions(-) diff --git a/src/Data/ModCache.lua b/src/Data/ModCache.lua index 2da06ea1c..96c4db6b9 100644 --- a/src/Data/ModCache.lua +++ b/src/Data/ModCache.lua @@ -5373,7 +5373,7 @@ c["Increases and Reductions to Minion Attack Speed also affect you"]={{[1]={flag c["Increases and Reductions to Minion Damage also affect you"]={{[1]={flags=0,keywordFlags=0,name="MinionDamageAppliesToPlayer",type="FLAG",value=true},[2]={flags=0,keywordFlags=0,name="ImprovedMinionDamageAppliesToPlayer",type="MAX",value=100}},nil} c["Increases and Reductions to Projectile Speed also apply to Damage with Bows"]={{[1]={flags=0,keywordFlags=0,name="ProjectileSpeedAppliesToBowDamage",type="FLAG",value=true}},nil} c["Increases and Reductions to Spell damage also apply to Attacks"]={{[1]={flags=0,keywordFlags=0,name="SpellDamageAppliesToAttacks",type="FLAG",value=true},[2]={flags=0,keywordFlags=0,name="ImprovedSpellDamageAppliesToAttacks",type="MAX",value=100}},nil} -c["Inevitable Critical Hits"]={{[1]={flags=0,keywordFlags=0,name="InevitableCriticalHits",type="FLAG",value=true}},nil} +c["Inevitable Critical Hits"]={{[1]={flags=0,keywordFlags=0,name="ForcedOutcome",type="FLAG",value=true},[2]={[1]={type="Condition",var="Combat"},flags=0,keywordFlags=0,name="Condition:CritRecently",source="Config",type="FLAG",value=true}},nil} c["Infinite Parry Range"]={nil,"Infinite Parry Range "} c["Infinite Parry Range 50% increased Parried Debuff Duration"]={nil,"Infinite Parry Range 50% increased Parried Debuff Duration "} c["Inflict Abyssal Wasting on Hit"]={nil,"Inflict Abyssal Wasting on Hit "} diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 0822a6e7e..d6d29edd4 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -3527,6 +3527,37 @@ function calcs.offence(env, actor, activeSkill) output.ScaledDamageEffect = 1 + output.ForcedOutcomeDamageEffect = 1 + + -- Calculate multiplier for Forced Outcome's Inevitiable Critical Hits + if activeSkill.skillModList:Flag(nil, "ForcedOutcome") then + + -- Use pre-effective to reroll only crit chance, not accuracy + local critChance = m_min(1, m_max(0, output.PreEffectiveCritChance / 100)) + + -- Consider lucky crits (they were previously only applied post-effective) + if skillModList:Flag(cfg, "CritChanceLucky") then + critChance = (1 - (1 - critChance) ^ 2) + end + + -- Combine the probabilities and 30% less multipliers for each case, 1/2/3/4+ rerolls + -- (This bonus is only applied to non-critical hits, so requires at least one reroll, with 70% being the max) + local nonCritChance = 1 - critChance + local critBonusMultiplier = + -- 70% crit damage, crit% of the time (after an initial non-crit) + 0.7 * critChance + + -- 40% if we roll non-crit then a crit + 0.4 * nonCritChance * critChance + + -- 10% if we roll two non-crits then a crit + 0.1 * nonCritChance * nonCritChance * critChance + -- (Implicitly 0% for 4 or more rerolls) + + -- Get the crit damage bonus from the multiplier, and apply our multiplier to it + local bonusMult = output.CritMultiplier - 1 + local modifiedBonus = bonusMult * critBonusMultiplier + output.ForcedOutcomeDamageEffect = 1 + modifiedBonus + end + -- Calculate chance and multiplier for dealing triple damage on Normal and Crit output.TripleDamageChanceOnCrit = m_min(skillModList:Sum("BASE", cfg, "TripleDamageChanceOnCrit"), 100) output.TripleDamageChance = m_min(skillModList:Sum("BASE", cfg, "TripleDamageChance") or 0 + (env.mode_effective and enemyDB:Sum("BASE", cfg, "SelfTripleDamageChance") or 0) + (output.TripleDamageChanceOnCrit * output.CritChance / 100), 100) @@ -3675,6 +3706,9 @@ function calcs.offence(env, actor, activeSkill) if globalOutput.MaxOffensiveWarcryEffect ~= 1 and activeSkill.skillModList:Flag(nil, "Condition:WarcryMaxHit") then t_insert(breakdown[damageType], s_format("x %.2f ^8(aggregated max warcry exerted effect modifier)", globalOutput.MaxOffensiveWarcryEffect)) end + if output.ForcedOutcomeDamageEffect ~= 1 then + t_insert(breakdown[damageType], s_format("x %.2f ^8(forced outcome damage modifier)", output.ForcedOutcomeDamageEffect)) + end end if activeSkill.skillModList:Flag(nil, "Condition:WarcryMaxHit") then output.allMult = output.ScaledDamageEffect * output.FistOfWarDamageEffect * globalOutput.MaxOffensiveWarcryEffect @@ -3685,36 +3719,9 @@ function calcs.offence(env, actor, activeSkill) if pass == 1 then -- Apply crit multiplier allMult = allMult * output.CritMultiplier - elseif activeSkill.skillModList:Flag(nil, "InevitableCriticalHits") then - -- Calculate average number of rerolls for a non-crit - -- Use pre-effective so we don't consider accuracy, which already scales DPS - local critChance = output.PreEffectiveCritChance - - -- Consider lucky crits because they were only applied post-effective - -- (not that they exist in POE2 for now, but just in case) - if skillModList:Flag(cfg, "CritChanceLucky") then - critChance = (1 - (1 - critChance / 100) ^ 2) * 100 - end - - local avgNumRerolls = 100 / critChance - 1 - local critBonusMultiplier = 1 - m_min(1, .3 * avgNumRerolls) - - -- Crit multiplier includes the base 100%, +some% bonus - -- but this penalty only applies to the some% bonus - local bonusMult = output.CritMultiplier - 1 - local modifiedBonus = bonusMult * critBonusMultiplier - local newCritMult = 1 + modifiedBonus - allMult = allMult * newCritMult - - if breakdown then - t_insert(breakdown[damageType], "") - t_insert(breakdown[damageType], "Inevitable Criticals: ") - t_insert(breakdown[damageType], s_format(" Base Crit Bonus: +%.2f%%", bonusMult * 100)) - t_insert(breakdown[damageType], s_format(" Avg Num Rerolls: %.2f", avgNumRerolls)) - t_insert(breakdown[damageType], s_format(" Avg Crit Bonus: +%.2f%%", modifiedBonus * 100)) - t_insert(breakdown[damageType], s_format("x %.2f ^8(Inevitable Crit Multiplier)", newCritMult)) - t_insert(breakdown[damageType], "") - end + else + -- Apply inevitable crit damage only on non-crits + allMult = allMult * output.ForcedOutcomeDamageEffect end damageTypeHitMin = damageTypeHitMin * allMult damageTypeHitMax = damageTypeHitMax * allMult diff --git a/src/Modules/ModParser.lua b/src/Modules/ModParser.lua index 50b3233c5..f68b08265 100644 --- a/src/Modules/ModParser.lua +++ b/src/Modules/ModParser.lua @@ -3305,7 +3305,7 @@ local specialModList = { mod("EnemyModifier", "LIST", { mod = mod("LightningExposure", "BASE", -20) }, { type = "ActorCondition", actor = "enemy", var = "EnemyInPresence" }), }, -- Druid -- Oracle - ["inevitable critical hits"] = { flag("InevitableCriticalHits") }, + ["inevitable critical hits"] = { flag("ForcedOutcome") }, ["walk the paths not taken"] = {}, ["gain the benefits of bonded modifiers on runes and idols"] = { flag("Condition:CanUseBondedModifiers"), From 1483eedd4123dd19059989495522a48251859d6e Mon Sep 17 00:00:00 2001 From: Dimencia Date: Sun, 11 Jan 2026 05:52:02 -0500 Subject: [PATCH 4/6] Fix mod cache, spelling, and add tests --- spec/System/TestAttacks_spec.lua | 154 +++++++++++++++++++++++++++++++ src/Data/ModCache.lua | 2 +- src/Modules/CalcOffence.lua | 2 +- 3 files changed, 156 insertions(+), 2 deletions(-) diff --git a/spec/System/TestAttacks_spec.lua b/spec/System/TestAttacks_spec.lua index ce1b60373..64a18621c 100644 --- a/spec/System/TestAttacks_spec.lua +++ b/spec/System/TestAttacks_spec.lua @@ -99,4 +99,158 @@ describe("TestAttacks", function() local incSpeed = build.calcsTab.mainEnv.player.activeSkillList[1].skillModList:Sum("INC", nil, "Speed") assert.are.equals(incSpeed, 99) end) + + it("correctly calculates critical hit damage", function() + -- Setup: Add weapon with no crit chance, and strip enemy defenses + -- changing enemy mods seems to get overwritten when mods are calculated, so it's easiest to just strip their defenses here + build.itemsTab:CreateDisplayItemFromRaw([[ + New Item + Heavy Bow + -100% increased critical hit chance + nearby enemies have 100% less armour + nearby enemies have 100% less evasion + ]]) + build.itemsTab:AddDisplayItem() + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + -- 1: Get base damage with no crits + local critChance = 0 + local critMult = 2 + assert.are.equals(critChance, build.calcsTab.mainOutput.CritChance) + assert.are.equals(critMult, build.calcsTab.mainOutput.CritMultiplier) + + local averageHit = build.calcsTab.mainOutput.MainHand.AverageHit + + -- 2: Add crits and validate crit damage + build.configTab.input.customMods = "+10% to critical hit chance" + build.configTab:BuildModList() + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + local critChance = build.calcsTab.mainOutput.CritChance / 100 + local newAvgHit = (1 - critChance) * averageHit + critChance * averageHit * critMult + assert.are.equals(newAvgHit, build.calcsTab.mainOutput.MainHand.AverageHit) + end) + + it("correctly calculates critical hit damage with static values", function() + -- Setup: Create a 1 damage weapon with no crit chance, and strip enemy defenses + build.itemsTab:CreateDisplayItemFromRaw([[ + New Item + Heavy Bow + Quality: 0 + -100% increased critical hit chance + -100% increased physical damage + adds 1 to 1 physical damage to attacks + nearby enemies have 100% less armour + nearby enemies have 100% less evasion + ]]) + build.itemsTab:AddDisplayItem() + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + -- 1: Validate base damage = 1 + assert.are.equals(0, build.calcsTab.mainOutput.MainHand.CritChance) + assert.are.equals(2, build.calcsTab.mainOutput.CritMultiplier) + assert.are.equals(1, build.calcsTab.mainOutput.MainHand.AverageHit) + + -- 2: Add crits and validate new damage = 1.1 (for a 10% crit chance) + build.configTab.input.customMods = "+10% to critical hit chance" + build.configTab:BuildModList() + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + assert.are.equals(1.1, build.calcsTab.mainOutput.MainHand.AverageHit) + end) + + it("correctly adds damage with oracle forced outcome", function() + -- Setup: Add weapon with no crit chance, and strip enemy defenses + build.itemsTab:CreateDisplayItemFromRaw([[ + New Item + Heavy Bow + -100% increased Critical Hit Chance + nearby enemies have 100% less armour + nearby enemies have 100% less evasion + ]]) + build.itemsTab:AddDisplayItem() + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + -- 1: Get base damage with no crits + local critChance = 0 + local critMult = 2 + assert.are.equals(critChance, build.calcsTab.mainOutput.CritChance) + assert.are.equals(critMult, build.calcsTab.mainOutput.CritMultiplier) + + local averageHit = build.calcsTab.mainOutput.MainHand.AverageHit + + -- 2: Add crits and forced outcome, and validate damage + build.configTab.input.customMods = [[ + +10% to critical hit chance + inevitable critical hits + ]] + build.configTab:BuildModList() + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + local critChance = build.calcsTab.mainOutput.CritChance / 100 + local nonCritChance = 1 - critChance + + local critDamageBonusMult = .7 * critChance + .4 * nonCritChance * critChance + .1 * nonCritChance * nonCritChance * critChance + local nonCritMult = 1 + (critMult - 1) * critDamageBonusMult + + local forcedExpectedAvgHit = nonCritChance * averageHit * nonCritMult + critChance * averageHit * critMult + assert.are.equals(forcedExpectedAvgHit, build.calcsTab.mainOutput.MainHand.AverageHit) + end) + + it("does not affect normal crit damage with oracle forced outcome", function() + -- Setup: Add weapon with no crit chance, and strip enemy defenses + build.itemsTab:CreateDisplayItemFromRaw([[ + New Item + Heavy Bow + -100% increased Critical Hit Chance + nearby enemies have 100% less armour + nearby enemies have 100% less evasion + ]]) + build.itemsTab:AddDisplayItem() + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + local averageHit = build.calcsTab.mainOutput.MainHand.AverageHit + + -- 1: Add forced outcome and validate no change in damage + build.configTab.input.customMods = "inevitable critical hits" + build.configTab:BuildModList() + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + assert.are.equals(averageHit, build.calcsTab.mainOutput.MainHand.AverageHit) + + -- 2: Set crit chance to 100%, remove forced outcome, and validate change in damage + build.configTab.input.customMods = "+100% to critical hit chance" + build.configTab:BuildModList() + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + assert.are_not.equals(averageHit, build.calcsTab.mainOutput.MainHand.AverageHit) + averageHit = build.calcsTab.mainOutput.MainHand.AverageHit + + -- 3: Add forced outcome and validate no change in damage + build.configTab.input.customMods = build.configTab.input.customMods .. "\ninevitable critical hits" + build.configTab:BuildModList() + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + assert.are.equals(averageHit, build.calcsTab.mainOutput.MainHand.AverageHit) + end) end) \ No newline at end of file diff --git a/src/Data/ModCache.lua b/src/Data/ModCache.lua index 7a2f354f0..f6578b599 100644 --- a/src/Data/ModCache.lua +++ b/src/Data/ModCache.lua @@ -5373,7 +5373,7 @@ c["Increases and Reductions to Minion Attack Speed also affect you"]={{[1]={flag c["Increases and Reductions to Minion Damage also affect you"]={{[1]={flags=0,keywordFlags=0,name="MinionDamageAppliesToPlayer",type="FLAG",value=true},[2]={flags=0,keywordFlags=0,name="ImprovedMinionDamageAppliesToPlayer",type="MAX",value=100}},nil} c["Increases and Reductions to Projectile Speed also apply to Damage with Bows"]={{[1]={flags=0,keywordFlags=0,name="ProjectileSpeedAppliesToBowDamage",type="FLAG",value=true}},nil} c["Increases and Reductions to Spell damage also apply to Attacks"]={{[1]={flags=0,keywordFlags=0,name="SpellDamageAppliesToAttacks",type="FLAG",value=true},[2]={flags=0,keywordFlags=0,name="ImprovedSpellDamageAppliesToAttacks",type="MAX",value=100}},nil} -c["Inevitable Critical Hits"]={{[1]={flags=0,keywordFlags=0,name="ForcedOutcome",type="FLAG",value=true},[2]={[1]={type="Condition",var="Combat"},flags=0,keywordFlags=0,name="Condition:CritRecently",source="Config",type="FLAG",value=true}},nil} +c["Inevitable Critical Hits"]={{[1]={flags=0,keywordFlags=0,name="ForcedOutcome",type="FLAG",value=true}},nil} c["Infinite Parry Range"]={nil,"Infinite Parry Range "} c["Infinite Parry Range 50% increased Parried Debuff Duration"]={nil,"Infinite Parry Range 50% increased Parried Debuff Duration "} c["Inflict Abyssal Wasting on Hit"]={nil,"Inflict Abyssal Wasting on Hit "} diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index d6d29edd4..80a97d7bd 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -3529,7 +3529,7 @@ function calcs.offence(env, actor, activeSkill) output.ForcedOutcomeDamageEffect = 1 - -- Calculate multiplier for Forced Outcome's Inevitiable Critical Hits + -- Calculate multiplier for Forced Outcome's Inevitable Critical Hits if activeSkill.skillModList:Flag(nil, "ForcedOutcome") then -- Use pre-effective to reroll only crit chance, not accuracy From a98676cf81fcf5c7324403093866be4e0f685b6e Mon Sep 17 00:00:00 2001 From: Dimencia Date: Sun, 11 Jan 2026 10:23:20 -0500 Subject: [PATCH 5/6] Change to 100% effective crit --- src/Modules/CalcOffence.lua | 82 +++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 39 deletions(-) diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 80a97d7bd..4e93aa08a 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -3424,8 +3424,44 @@ function calcs.offence(env, actor, activeSkill) end output.PreBifurcateCritChance = output.CritChance local preBifurcateCritChance = output.CritChance + local bifurcateCritChance = output.CritChance if env.mode_effective and skillModList:Flag(cfg, "BifurcateCrit") then - output.CritChance = (1 - (1 - output.CritChance / 100) ^ 2) * 100 + bifurcateCritChance = (1 - (1 - output.CritChance / 100) ^ 2) * 100 + end + output.CritChance = bifurcateCritChance + + if activeSkill.skillModList:Flag(cfg, "ForcedOutcome") then + -- Lucky Crits and Bifurcation always force a reroll and thus always impose a 30% less penalty on crit mult, even when critting on the first hit + -- https://poe2db.tw/Rerolling_Critical_Hit_Chance + if env.mode_effective and skillModList:Flag(nil, "BifurcateCrit") then + skillModList:NewMod("CritMultiplier", "MORE", -.3, "Forced Outcome + Bifurcate Crits ^8(Always rerolls)") + end + if env.mode_effective and skillModList:Flag(nil, "CritChanceLucky") then + skillModList:NewMod("CritMultiplier", "MORE", -.3, "Forced Outcome + Lucky Crits ^8(Always rerolls)") + end + + -- Otherwise ignore them by taking PreEffective - we're already rerolling forever, so theirs don't do anything + local critChance = m_min(1, m_max(0, output.PreEffectiveCritChance / 100)) + + -- Combine the probabilities and 30% less multipliers for each case, 0/1/2/3/4+ rerolls + local nonCritChance = 1 - critChance + local critBonusMultiplier = + -- 100% crit damage, crit% of the time + 1 * critChance + + -- 70% if we roll non-crit then a crit + 0.7 * nonCritChance * critChance + + -- 40% if we roll two non-crit then a crit + 0.4 * nonCritChance * nonCritChance * critChance + + -- 10% if we roll three non-crits then a crit + 0.1 * nonCritChance * nonCritChance * nonCritChance * critChance + -- (Implicitly 0% for 4 or more rerolls) + + local lessCritBonus = (1 - critBonusMultiplier) * -100 + skillModList:NewMod("CritMultiplier", "MORE", lessCritBonus, "Forced Outcome") -- "Tree:[55135]" doesn't parse a name in the breakdown... + + if env.mode_effective then + output.CritChance = 100 + end end if breakdown and output.CritChance ~= baseCrit then breakdown.CritChance = { } @@ -3461,7 +3497,12 @@ function calcs.offence(env, actor, activeSkill) if env.mode_effective and skillModList:Flag(cfg, "BifurcateCrit") then t_insert(breakdown.CritChance, "Critical Strike Bifurcates:") t_insert(breakdown.CritChance, s_format("1 - (1 - %.4f) x (1 - %.4f)", preBifurcateCritChance / 100, preBifurcateCritChance / 100)) - t_insert(breakdown.CritChance, s_format("= %.2f%%", output.CritChance)) + t_insert(breakdown.CritChance, s_format("= %.2f%%", bifurcateCritChance)) + end + if env.mode_effective and skillModList:Flag(cfg, "ForcedOutcome") then + t_insert(breakdown.CritChance, "") + t_insert(breakdown.CritChance, "Forced Outcome:") + t_insert(breakdown.CritChance, "= 100% ^8(always crits)") end end end @@ -3527,37 +3568,6 @@ function calcs.offence(env, actor, activeSkill) output.ScaledDamageEffect = 1 - output.ForcedOutcomeDamageEffect = 1 - - -- Calculate multiplier for Forced Outcome's Inevitable Critical Hits - if activeSkill.skillModList:Flag(nil, "ForcedOutcome") then - - -- Use pre-effective to reroll only crit chance, not accuracy - local critChance = m_min(1, m_max(0, output.PreEffectiveCritChance / 100)) - - -- Consider lucky crits (they were previously only applied post-effective) - if skillModList:Flag(cfg, "CritChanceLucky") then - critChance = (1 - (1 - critChance) ^ 2) - end - - -- Combine the probabilities and 30% less multipliers for each case, 1/2/3/4+ rerolls - -- (This bonus is only applied to non-critical hits, so requires at least one reroll, with 70% being the max) - local nonCritChance = 1 - critChance - local critBonusMultiplier = - -- 70% crit damage, crit% of the time (after an initial non-crit) - 0.7 * critChance + - -- 40% if we roll non-crit then a crit - 0.4 * nonCritChance * critChance + - -- 10% if we roll two non-crits then a crit - 0.1 * nonCritChance * nonCritChance * critChance - -- (Implicitly 0% for 4 or more rerolls) - - -- Get the crit damage bonus from the multiplier, and apply our multiplier to it - local bonusMult = output.CritMultiplier - 1 - local modifiedBonus = bonusMult * critBonusMultiplier - output.ForcedOutcomeDamageEffect = 1 + modifiedBonus - end - -- Calculate chance and multiplier for dealing triple damage on Normal and Crit output.TripleDamageChanceOnCrit = m_min(skillModList:Sum("BASE", cfg, "TripleDamageChanceOnCrit"), 100) output.TripleDamageChance = m_min(skillModList:Sum("BASE", cfg, "TripleDamageChance") or 0 + (env.mode_effective and enemyDB:Sum("BASE", cfg, "SelfTripleDamageChance") or 0) + (output.TripleDamageChanceOnCrit * output.CritChance / 100), 100) @@ -3706,9 +3716,6 @@ function calcs.offence(env, actor, activeSkill) if globalOutput.MaxOffensiveWarcryEffect ~= 1 and activeSkill.skillModList:Flag(nil, "Condition:WarcryMaxHit") then t_insert(breakdown[damageType], s_format("x %.2f ^8(aggregated max warcry exerted effect modifier)", globalOutput.MaxOffensiveWarcryEffect)) end - if output.ForcedOutcomeDamageEffect ~= 1 then - t_insert(breakdown[damageType], s_format("x %.2f ^8(forced outcome damage modifier)", output.ForcedOutcomeDamageEffect)) - end end if activeSkill.skillModList:Flag(nil, "Condition:WarcryMaxHit") then output.allMult = output.ScaledDamageEffect * output.FistOfWarDamageEffect * globalOutput.MaxOffensiveWarcryEffect @@ -3719,9 +3726,6 @@ function calcs.offence(env, actor, activeSkill) if pass == 1 then -- Apply crit multiplier allMult = allMult * output.CritMultiplier - else - -- Apply inevitable crit damage only on non-crits - allMult = allMult * output.ForcedOutcomeDamageEffect end damageTypeHitMin = damageTypeHitMin * allMult damageTypeHitMax = damageTypeHitMax * allMult From 6fffb7a7f28ce76708e7eaa5d60f49250559c537 Mon Sep 17 00:00:00 2001 From: Dimencia Date: Sun, 11 Jan 2026 13:48:15 -0500 Subject: [PATCH 6/6] Clean up crit logic, fix test --- spec/System/TestAttacks_spec.lua | 63 ++------- src/Modules/CalcOffence.lua | 215 ++++++++++++++++--------------- 2 files changed, 125 insertions(+), 153 deletions(-) diff --git a/spec/System/TestAttacks_spec.lua b/spec/System/TestAttacks_spec.lua index 64a18621c..2351bd51b 100644 --- a/spec/System/TestAttacks_spec.lua +++ b/spec/System/TestAttacks_spec.lua @@ -182,7 +182,7 @@ describe("TestAttacks", function() runCallback("OnFrame") -- 1: Get base damage with no crits - local critChance = 0 + local critChance = 0.0 local critMult = 2 assert.are.equals(critChance, build.calcsTab.mainOutput.CritChance) assert.are.equals(critMult, build.calcsTab.mainOutput.CritMultiplier) @@ -199,58 +199,23 @@ describe("TestAttacks", function() build.calcsTab:BuildOutput() runCallback("OnFrame") - local critChance = build.calcsTab.mainOutput.CritChance / 100 + critChance = 0.1 local nonCritChance = 1 - critChance - local critDamageBonusMult = .7 * critChance + .4 * nonCritChance * critChance + .1 * nonCritChance * nonCritChance * critChance - local nonCritMult = 1 + (critMult - 1) * critDamageBonusMult - - local forcedExpectedAvgHit = nonCritChance * averageHit * nonCritMult + critChance * averageHit * critMult - assert.are.equals(forcedExpectedAvgHit, build.calcsTab.mainOutput.MainHand.AverageHit) - end) - - it("does not affect normal crit damage with oracle forced outcome", function() - -- Setup: Add weapon with no crit chance, and strip enemy defenses - build.itemsTab:CreateDisplayItemFromRaw([[ - New Item - Heavy Bow - -100% increased Critical Hit Chance - nearby enemies have 100% less armour - nearby enemies have 100% less evasion - ]]) - build.itemsTab:AddDisplayItem() - runCallback("OnFrame") - build.calcsTab:BuildOutput() - runCallback("OnFrame") - - local averageHit = build.calcsTab.mainOutput.MainHand.AverageHit - - -- 1: Add forced outcome and validate no change in damage - build.configTab.input.customMods = "inevitable critical hits" - build.configTab:BuildModList() - runCallback("OnFrame") - build.calcsTab:BuildOutput() - runCallback("OnFrame") - - assert.are.equals(averageHit, build.calcsTab.mainOutput.MainHand.AverageHit) - - -- 2: Set crit chance to 100%, remove forced outcome, and validate change in damage - build.configTab.input.customMods = "+100% to critical hit chance" - build.configTab:BuildModList() - runCallback("OnFrame") - build.calcsTab:BuildOutput() - runCallback("OnFrame") + local critBonusMultiplier = + 1 * critChance + + .7 * nonCritChance * critChance + + .4 * nonCritChance * nonCritChance * critChance + + .1 * nonCritChance * nonCritChance * nonCritChance * critChance - assert.are_not.equals(averageHit, build.calcsTab.mainOutput.MainHand.AverageHit) - averageHit = build.calcsTab.mainOutput.MainHand.AverageHit + -- When adding them as MORE mods, they get auto rounded after *100, so we need to do the same + critBonusMultiplier = math.floor(critBonusMultiplier * 100 + 0.5)/100 - -- 3: Add forced outcome and validate no change in damage - build.configTab.input.customMods = build.configTab.input.customMods .. "\ninevitable critical hits" - build.configTab:BuildModList() - runCallback("OnFrame") - build.calcsTab:BuildOutput() - runCallback("OnFrame") + local critBonus = critMult - 1 + critBonus = critBonus * critBonusMultiplier + critMult = 1 + critBonus - assert.are.equals(averageHit, build.calcsTab.mainOutput.MainHand.AverageHit) + local forcedExpectedAvgHit = averageHit * critMult + assert.are.equals(forcedExpectedAvgHit, build.calcsTab.mainOutput.MainHand.AverageHit) end) end) \ No newline at end of file diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 4e93aa08a..ec10e1830 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -3374,6 +3374,26 @@ function calcs.offence(env, actor, activeSkill) --else -- this shouldn't ever be a case but leaving this here if someone wants to implement it end else + -- Helpers + local addBreakdown = function(formatString, ...) + if not breakdown then return end + breakdown.CritChance = breakdown.CritChance or {} + table.insert(breakdown.CritChance, s_format(formatString, ...)) + end + + local printedEffective = false + local ensureEffectivePrinted = function() + if printedEffective or not env.mode_effective then return end + printedEffective = true + + addBreakdown("") + addBreakdown("Effective Crit Chance:") + addBreakdown(" %.2f%% ^8(base)", output.PreEffectiveCritChance) + end + + addBreakdown("Base Crit Chance:") + + -- Override local critOverride = skillModList:Override(cfg, "CritChance") -- destructive link if skillModList:Flag(cfg, "MainHandCritIsEqualToParent") then @@ -3394,119 +3414,106 @@ function calcs.offence(env, actor, activeSkill) baseCrit = actor.parent.weaponData1 and actor.parent.weaponData1.CritChance or baseCrit end - if critOverride == 100 then - output.PreEffectiveCritChance = 100 - output.PreBifurcateCritChance = 100 - output.CritChance = 100 + -- PreEffective + if critOverride then + addBreakdown("%g ^8(override)", critOverride) + + output.PreEffectiveCritChance = critOverride else - local base = 0 - local inc = 0 - local more = 1 - if not critOverride then - base = skillModList:Sum("BASE", cfg, "CritChance") + (env.mode_effective and enemyDB:Sum("BASE", nil, "SelfCritChance") or 0) - inc = skillModList:Sum("INC", cfg, "CritChance") + (env.mode_effective and enemyDB:Sum("INC", nil, "SelfCritChance") or 0) - more = skillModList:More(cfg, "CritChance") - end - output.CritChance = (baseCrit + base) * (1 + inc / 100) * more - local preCapCritChance = output.CritChance - output.CritChance = m_min(output.CritChance, skillModList:Override(nil, "CritChanceCap") or skillModList:Sum("BASE", cfg, "CritChanceCap")) - if (baseCrit + base) > 0 then - output.CritChance = m_max(output.CritChance, 0) - end - output.PreEffectiveCritChance = output.CritChance - local preHitCheckCritChance = output.CritChance - if env.mode_effective then - output.CritChance = output.CritChance * output.AccuracyHitChance / 100 + local base = skillModList:Sum("BASE", cfg, "CritChance") + (env.mode_effective and enemyDB:Sum("BASE", nil, "SelfCritChance") or 0) + local baseCritFromMainHandStr = baseCritFromMainHand and " from main weapon" or baseCritFromParentMainHand and " from parent main weapon" or "" + if base ~= 0 then + addBreakdown(" (%g + %g)%% ^8(base%s)", baseCrit, base, baseCritFromMainHandStr) + else + addBreakdown(" %g%% ^8(base%s)", baseCrit + base, baseCritFromMainHandStr) end - local preLuckyCritChance = output.CritChance - if env.mode_effective and skillModList:Flag(cfg, "CritChanceLucky") then - output.CritChance = (1 - (1 - output.CritChance / 100) ^ 2) * 100 + + local inc = skillModList:Sum("INC", cfg, "CritChance") + (env.mode_effective and enemyDB:Sum("INC", nil, "SelfCritChance") or 0) + if inc ~= 0 then + addBreakdown(" x %.2f ^8(increased/reduced)", 1 + inc/100) end - output.PreBifurcateCritChance = output.CritChance - local preBifurcateCritChance = output.CritChance - local bifurcateCritChance = output.CritChance - if env.mode_effective and skillModList:Flag(cfg, "BifurcateCrit") then - bifurcateCritChance = (1 - (1 - output.CritChance / 100) ^ 2) * 100 + + local more = skillModList:More(cfg, "CritChance") + if more ~= 1 then + addBreakdown(" x %.2f ^8(more/less)", more) end - output.CritChance = bifurcateCritChance + + output.PreEffectiveCritChance = (baseCrit + base) * (1 + inc / 100) * more + + if output.PreEffectiveCritChance > 100 then + local overCap = output.PreEffectiveCritChance - 100 + addBreakdown("Crit is overcapped by %.2f%% (%d%% increased Critical Hit Chance)", overCap, overCap / more / (baseCrit + base) * 100) + end + end + + local critChanceCap = skillModList:Override(nil, "CritChanceCap") or skillModList:Sum("BASE", cfg, "CritChanceCap") + if critChanceCap < output.PreEffectiveCritChance or output.PreEffectiveCritChance < 0 then + addBreakdown("Clamp between 0 and crit chance cap: %f", critChanceCap) + end + + output.PreEffectiveCritChance = m_max(0, m_min(output.PreEffectiveCritChance, critChanceCap)) + addBreakdown("= %.2f%% ^8(crit chance)", output.PreEffectiveCritChance) + + + -- Effective + output.CritChance = output.PreEffectiveCritChance + + -- Inevitable Critical Hits (Forced Outcome) + if env.mode_effective and skillModList:Flag(cfg, "ForcedOutcome") then + local critChance = output.CritChance / 100 + local nonCritChance = 1 - critChance - if activeSkill.skillModList:Flag(cfg, "ForcedOutcome") then - -- Lucky Crits and Bifurcation always force a reroll and thus always impose a 30% less penalty on crit mult, even when critting on the first hit - -- https://poe2db.tw/Rerolling_Critical_Hit_Chance - if env.mode_effective and skillModList:Flag(nil, "BifurcateCrit") then - skillModList:NewMod("CritMultiplier", "MORE", -.3, "Forced Outcome + Bifurcate Crits ^8(Always rerolls)") - end - if env.mode_effective and skillModList:Flag(nil, "CritChanceLucky") then - skillModList:NewMod("CritMultiplier", "MORE", -.3, "Forced Outcome + Lucky Crits ^8(Always rerolls)") - end + local critBonusMultiplier = + 1 * critChance + -- 100% crit damage, crit% of the time + 0.7 * nonCritChance * critChance + -- 70% if we roll non-crit then a crit + 0.4 * math.pow(nonCritChance, 2) * critChance + -- 40% if we roll two non-crit then a crit + 0.1 * math.pow(nonCritChance, 3) * critChance -- 10% if we roll three non-crits then a crit - -- Otherwise ignore them by taking PreEffective - we're already rerolling forever, so theirs don't do anything - local critChance = m_min(1, m_max(0, output.PreEffectiveCritChance / 100)) - - -- Combine the probabilities and 30% less multipliers for each case, 0/1/2/3/4+ rerolls - local nonCritChance = 1 - critChance - local critBonusMultiplier = - -- 100% crit damage, crit% of the time - 1 * critChance + - -- 70% if we roll non-crit then a crit - 0.7 * nonCritChance * critChance + - -- 40% if we roll two non-crit then a crit - 0.4 * nonCritChance * nonCritChance * critChance + - -- 10% if we roll three non-crits then a crit - 0.1 * nonCritChance * nonCritChance * nonCritChance * critChance - -- (Implicitly 0% for 4 or more rerolls) - - local lessCritBonus = (1 - critBonusMultiplier) * -100 - skillModList:NewMod("CritMultiplier", "MORE", lessCritBonus, "Forced Outcome") -- "Tree:[55135]" doesn't parse a name in the breakdown... - - if env.mode_effective then - output.CritChance = 100 - end + -- This gets rounded when used in damage logic, so round it ahead of time to make the breakdown accurate (and less ugly) + local lessCritBonus = math.floor((1 - critBonusMultiplier) * -100.0 + 0.5) + skillModList:NewMod("CritMultiplier", "MORE", lessCritBonus, "Forced Outcome") + + -- Lucky Crits and Bifurcation always roll twice on the initial hit, so they always have an extra penalty, even when critting on the first hit + -- https://poe2db.tw/Rerolling_Critical_Hit_Chance + if skillModList:Flag(nil, "BifurcateCrit") then + skillModList:NewMod("CritMultiplier", "MORE", -30, "Forced Outcome + Bifurcate Crits ^8(Always rerolls)") end - if breakdown and output.CritChance ~= baseCrit then - breakdown.CritChance = { } - local baseCritFromMainHandStr = baseCritFromMainHand and " from main weapon" or baseCritFromParentMainHand and " from parent main weapon" or "" - if base ~= 0 then - t_insert(breakdown.CritChance, s_format("(%g + %g) ^8(base%s)", baseCrit, base, baseCritFromMainHandStr)) - else - t_insert(breakdown.CritChance, s_format("%g ^8(base%s)", baseCrit + base, baseCritFromMainHandStr)) - end - if inc ~= 0 then - t_insert(breakdown.CritChance, s_format("x %.2f", 1 + inc/100).." ^8(increased/reduced)") - end - if more ~= 1 then - t_insert(breakdown.CritChance, s_format("x %.2f", more).." ^8(more/less)") - end - t_insert(breakdown.CritChance, s_format("= %.2f%% ^8(crit chance)", output.PreEffectiveCritChance)) - if preCapCritChance > 100 then - local overCap = preCapCritChance - 100 - t_insert(breakdown.CritChance, s_format("Crit is overcapped by %.2f%% (%d%% increased Critical Hit Chance)", overCap, overCap / more / (baseCrit + base) * 100)) - end - if env.mode_effective and output.AccuracyHitChance < 100 then - t_insert(breakdown.CritChance, "") - t_insert(breakdown.CritChance, "Effective Crit Chance:") - t_insert(breakdown.CritChance, s_format("%.2f%%", preHitCheckCritChance)) - t_insert(breakdown.CritChance, s_format("x %.2f ^8(chance to hit)", output.AccuracyHitChance / 100)) - t_insert(breakdown.CritChance, s_format("= %.2f%%", preLuckyCritChance)) - end - if env.mode_effective and skillModList:Flag(cfg, "CritChanceLucky") then - t_insert(breakdown.CritChance, "Crit Chance is Lucky:") - t_insert(breakdown.CritChance, s_format("1 - (1 - %.4f) x (1 - %.4f)", preLuckyCritChance / 100, preLuckyCritChance / 100)) - t_insert(breakdown.CritChance, s_format("= %.2f%%", preBifurcateCritChance)) - end - if env.mode_effective and skillModList:Flag(cfg, "BifurcateCrit") then - t_insert(breakdown.CritChance, "Critical Strike Bifurcates:") - t_insert(breakdown.CritChance, s_format("1 - (1 - %.4f) x (1 - %.4f)", preBifurcateCritChance / 100, preBifurcateCritChance / 100)) - t_insert(breakdown.CritChance, s_format("= %.2f%%", bifurcateCritChance)) - end - if env.mode_effective and skillModList:Flag(cfg, "ForcedOutcome") then - t_insert(breakdown.CritChance, "") - t_insert(breakdown.CritChance, "Forced Outcome:") - t_insert(breakdown.CritChance, "= 100% ^8(always crits)") - end + if skillModList:Flag(nil, "CritChanceLucky") then + skillModList:NewMod("CritMultiplier", "MORE", -30, "Forced Outcome + Lucky Crits ^8(Always rerolls)") + end + + ensureEffectivePrinted() + output.CritChance = 100 + addBreakdown("= %.2f%% ^8(forced outcome)", output.CritChance) + else + -- Ignore these if we have Forced Outcome, their rerolls are meaningless when we already reroll until we crit + + -- Lucky Critical Hits + if env.mode_effective and skillModList:Flag(cfg, "CritChanceLucky") then + ensureEffectivePrinted() + addBreakdown("1 - (1 - %.4f) x (1 - %.4f)", output.CritChance / 100, output.CritChance / 100) + output.CritChance = (1 - (1 - output.CritChance / 100) ^ 2) * 100 + addBreakdown("= %.2f%% ^8(lucky crits)", output.CritChance) + end + + -- Bifurcated Critical Hits + if env.mode_effective and skillModList:Flag(cfg, "BifurcateCrit") then + ensureEffectivePrinted() + addBreakdown("1 - (1 - %.4f) x (1 - %.4f)", output.CritChance / 100, output.CritChance / 100) + output.CritChance = (1 - (1 - output.CritChance / 100) ^ 2) * 100 + addBreakdown("= %.2f%% ^8(bifurcated crits)", output.CritChance) end end + + -- Accuracy + if env.mode_effective and output.AccuracyHitChance < 100 then + ensureEffectivePrinted() + addBreakdown(" x %.2f ^8(chance to hit)", output.AccuracyHitChance / 100) + output.CritChance = output.CritChance * output.AccuracyHitChance / 100 + addBreakdown("= %.2f%%", output.CritChance) + end end + if not output.CritEffect then if skillModList:Flag(cfg, "NoCritMultiplier") then output.CritMultiplier = 1 @@ -3524,7 +3531,7 @@ function calcs.offence(env, actor, activeSkill) -- if crit bifurcates are enabled, roll for crit twice and add multiplier for each if env.mode_effective and skillModList:Flag(cfg, "BifurcateCrit") then -- get crit chance and calculate odds of critting twice - local critChancePercentage = output.PreBifurcateCritChance + local critChancePercentage = output.PreEffectiveCritChance local bifurcateMultiChance = (critChancePercentage ^ 2) / 100 output.CritBifurcates = bifurcateMultiChance local damageBonus = extraDamage