Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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 "}
Expand Down
174 changes: 111 additions & 63 deletions src/Modules/CalcOffence.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/Modules/ModParser.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading