diff --git a/spec/System/TestAttacks_spec.lua b/spec/System/TestAttacks_spec.lua index ce1b60373a..2b02031668 100644 --- a/spec/System/TestAttacks_spec.lua +++ b/spec/System/TestAttacks_spec.lua @@ -99,4 +99,187 @@ describe("TestAttacks", function() local incSpeed = build.calcsTab.mainEnv.player.activeSkillList[1].skillModList:Sum("INC", nil, "Speed") assert.are.equals(incSpeed, 99) end) -end) \ No newline at end of file + + 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.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") + + critChance = 0.1 + local nonCritChance = 1 - critChance + + local critBonusMultiplier = + 1 * critChance + + .7 * nonCritChance * critChance + + .4 * nonCritChance * nonCritChance * critChance + + .1 * nonCritChance * nonCritChance * nonCritChance * critChance + + -- 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 + + local critBonus = critMult - 1 + critBonus = critBonus * critBonusMultiplier + critMult = 1 + critBonus + + local forcedExpectedAvgHit = averageHit * critMult + assert.are.equals(forcedExpectedAvgHit, build.calcsTab.mainOutput.MainHand.AverageHit) + end) + + it("does not force critical hits when critical hit chance is zero", function() + 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.configTab.input.customMods = "inevitable critical hits" + build.configTab:BuildModList() + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + assert.are.equals(0, build.calcsTab.mainOutput.MainHand.CritChance) + assert.are.equals(1, build.calcsTab.mainOutput.MainHand.AverageHit) + end) + + it("correctly calculates forced outcome with bifurcated critical hits", function() + 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.configTab.input.customMods = [[ + +10% to critical hit chance + inevitable critical hits + bifurcates critical hits + ]] + build.configTab:BuildModList() + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + local critChance = 0.1 + local failedStageChance = (1 - critChance) ^ 2 + local critBonusMultiplier = 2 * critChance * ( + 1 + + .7 * failedStageChance + + .4 * failedStageChance ^ 2 + + .1 * failedStageChance ^ 3 + ) + critBonusMultiplier = math.floor(critBonusMultiplier * 100 + 0.5) / 100 + + assert.are.equals(100, build.calcsTab.mainOutput.MainHand.CritChance) + local bifurcateChance = (critChance * 100) ^ 2 / ((1 - failedStageChance) * 100) + assert.is_true(math.abs(bifurcateChance - build.calcsTab.mainOutput.MainHand.CritBifurcates) < 0.000001) + assert.are.equals(1 + critBonusMultiplier, build.calcsTab.mainOutput.MainHand.AverageHit) + end) +end) diff --git a/src/Data/ModCache.lua b/src/Data/ModCache.lua index c91694c01a..61045d5f6c 100644 --- a/src/Data/ModCache.lua +++ b/src/Data/ModCache.lua @@ -5371,7 +5371,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="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 eeab607887..20d3ccba7e 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -3340,6 +3340,7 @@ function calcs.offence(env, actor, activeSkill) end -- Calculate crit chance, crit multiplier, and their combined effect + local forcedOutcomeApplies = false if skillModList:Flag(cfg, "NeverCrit") then output.PreEffectiveCritChance = 0 output.CritChance = 0 @@ -3416,6 +3417,8 @@ function calcs.offence(env, actor, activeSkill) output.PreEffectiveCritChance = output.CritChance local preHitCheckCritChance = output.CritChance if env.mode_effective then + -- Hits that make an accuracy check make a second accuracy check if they roll a crit. If that fails, the crit is nullified and it just stays a normal hit + -- https://www.pathofexile.com/forum/view-thread/11707/filter-account-type/staff/page/10#p748465 output.CritChance = output.CritChance * output.AccuracyHitChance / 100 end local preLuckyCritChance = output.CritChance @@ -3427,6 +3430,34 @@ function calcs.offence(env, actor, activeSkill) if env.mode_effective and skillModList:Flag(cfg, "BifurcateCrit") then output.CritChance = (1 - (1 - output.CritChance / 100) ^ 2) * 100 end + local preForcedOutcomeCritChance = output.CritChance + if env.mode_effective and skillModList:Flag(cfg, "ForcedOutcome") and output.CritChance > 0 then + forcedOutcomeApplies = true + -- Lucky crits use their effective crit chance without an extra roll-down penalty. + -- Bifurcated crits roll twice per roll-down; only both rolls failing advances the penalty. + local critChance = output.CritChance / 100 + local nonCritChance = 1 - critChance + + 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 * m_pow(nonCritChance, 2) * critChance + -- 40% if we roll two non-crit then a crit + 0.1 * m_pow(nonCritChance, 3) * critChance -- 10% if we roll three non-crits then a crit + if skillModList:Flag(cfg, "BifurcateCrit") then + -- The terminating stage can crit twice, so scale its one-or-more-crit bonus + -- by the expected number of crits on that stage. + critBonusMultiplier = critBonusMultiplier * 2 * output.PreBifurcateCritChance / output.CritChance + output.CritBifurcates = output.PreBifurcateCritChance ^ 2 / output.CritChance + 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 = round((1 - critBonusMultiplier) * -100.0, 0) + skillModList:NewMod("CritMultiplier", "MORE", lessCritBonus, "Tree:55135") + + -- For the sake of any logic that depends on it, every hit is considered a crit + output.CritChance = 100 + skillModList:NewMod("CritChance", "OVERRIDE", 100, "Tree:55135") + 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 "" @@ -3461,7 +3492,11 @@ 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%%", preForcedOutcomeCritChance)) + end + if forcedOutcomeApplies then + t_insert(breakdown.CritChance, "Inevitable Critical Hits (Forced Outcome):") + t_insert(breakdown.CritChance, "= 100% ^8(override)") end end end @@ -3481,7 +3516,7 @@ function calcs.offence(env, actor, activeSkill) output.PreEffectiveCritMultiplier = 1 + extraDamage -- if crit bifurcates are enabled, roll for crit twice and add multiplier for each - if env.mode_effective and skillModList:Flag(cfg, "BifurcateCrit") then + if env.mode_effective and skillModList:Flag(cfg, "BifurcateCrit") and not forcedOutcomeApplies then -- get crit chance and calculate odds of critting twice local critChancePercentage = output.PreBifurcateCritChance local bifurcateMultiChance = (critChancePercentage ^ 2) / 100 @@ -5869,4 +5904,4 @@ function calcs.offence(env, actor, activeSkill) output.CullingDPS = output.CombinedDPS * (bestCull - 1) output.ReservationDPS = output.CombinedDPS * (output.ReservationDpsMultiplier - 1) output.CombinedDPS = output.CombinedDPS * bestCull * output.ReservationDpsMultiplier -end +end \ No newline at end of file diff --git a/src/Modules/ModParser.lua b/src/Modules/ModParser.lua index 8c6d5b2914..26b1a58426 100644 --- a/src/Modules/ModParser.lua +++ b/src/Modules/ModParser.lua @@ -3312,6 +3312,7 @@ local specialModList = { mod("EnemyModifier", "LIST", { mod = mod("LightningExposure", "BASE", -20) }, { type = "ActorCondition", actor = "enemy", var = "EnemyInPresence" }), }, -- Druid -- Oracle + ["inevitable critical hits"] = { flag("ForcedOutcome") }, ["walk the paths not taken"] = { }, ["gain the benefits of bonded modifiers on runes and idols"] = { flag("Condition:CanUseBondedModifiers"),