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 a18331cf2..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"]={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..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,78 +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 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 + + local more = skillModList:More(cfg, "CritChance") + if more ~= 1 then + addBreakdown(" x %.2f ^8(more/less)", more) + end + + 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 - local preLuckyCritChance = output.CritChance + 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 + + 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 + + -- 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 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 - output.PreBifurcateCritChance = output.CritChance - local preBifurcateCritChance = output.CritChance + + -- 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 - 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%%", output.CritChance)) - end + 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 @@ -3483,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 diff --git a/src/Modules/ModParser.lua b/src/Modules/ModParser.lua index 52280d136..ffb08c186 100644 --- a/src/Modules/ModParser.lua +++ b/src/Modules/ModParser.lua @@ -3307,6 +3307,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"),