Skip to content
119 changes: 119 additions & 0 deletions spec/System/TestAttacks_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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
29 changes: 28 additions & 1 deletion src/Modules/CalcOffence.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
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