diff --git a/spec/System/TestAttacks_spec.lua b/spec/System/TestAttacks_spec.lua index ce1b60373..2351bd51b 100644 --- a/spec/System/TestAttacks_spec.lua +++ b/spec/System/TestAttacks_spec.lua @@ -99,4 +99,123 @@ 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.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) end) \ No newline at end of file diff --git a/src/Data/ModCache.lua b/src/Data/ModCache.lua index c91694c01..61045d5f6 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 eeab60788..f719d564d 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -3416,6 +3416,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 +3429,27 @@ 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 + if env.mode_effective and skillModList:Flag(cfg, "ForcedOutcome") then + -- Lucky crits: not considered a 'reroll' for the sake of forced outcome penalties, just a higher crit chance + -- Bifurcated crits: only considered a 'reroll' if both of them fail to crit + -- So just use the crit chance as calculated, and it already works with both of them + 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 + + -- 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 "" @@ -3463,6 +3486,10 @@ function calcs.offence(env, actor, activeSkill) 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)) end + if env.mode_effective and skillModList:Flag(cfg, "ForcedOutcome") then + t_insert(breakdown.CritChance, "Inevitable Critical Hits (Forced Outcome):") + t_insert(breakdown.CritChance, "= 100% ^8(override)") + end end end end @@ -5869,4 +5896,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 8c6d5b291..26b1a5842 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"),