Skip to content
Merged
185 changes: 184 additions & 1 deletion spec/System/TestAttacks_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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)
2 changes: 1 addition & 1 deletion src/Data/ModCache.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 "}
Expand Down
41 changes: 38 additions & 3 deletions src/Modules/CalcOffence.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 ""
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions src/Modules/ModParser.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading