Skip to content

Commit 2285be9

Browse files
DimenciaLocalIdentity
andauthored
Add support for "Forced Outcome" (#1698)
* Add handling for forced outcome * Consider lucky crits * Rename to forced outcome to avoid ambiguation with support gem, fix calculations, move calculations * Fix mod cache, spelling, and add tests * Change to 100% effective crit * Clean up crit logic, fix test * Restore accuracy consideration, fix lucky and bifurcate interaction * Use localized math functions and fix formatting * Add test + fix interaction with bifurcated crits --------- Co-authored-by: LocalIdentity <localidentity2@gmail.com>
1 parent cd1d4d2 commit 2285be9

4 files changed

Lines changed: 224 additions & 5 deletions

File tree

spec/System/TestAttacks_spec.lua

Lines changed: 184 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,187 @@ describe("TestAttacks", function()
9999
local incSpeed = build.calcsTab.mainEnv.player.activeSkillList[1].skillModList:Sum("INC", nil, "Speed")
100100
assert.are.equals(incSpeed, 99)
101101
end)
102-
end)
102+
103+
it("correctly calculates critical hit damage", function()
104+
-- Setup: Add weapon with no crit chance, and strip enemy defenses
105+
-- changing enemy mods seems to get overwritten when mods are calculated, so it's easiest to just strip their defenses here
106+
build.itemsTab:CreateDisplayItemFromRaw([[
107+
New Item
108+
Heavy Bow
109+
-100% increased critical hit chance
110+
nearby enemies have 100% less armour
111+
nearby enemies have 100% less evasion
112+
]])
113+
build.itemsTab:AddDisplayItem()
114+
runCallback("OnFrame")
115+
build.calcsTab:BuildOutput()
116+
runCallback("OnFrame")
117+
118+
-- 1: Get base damage with no crits
119+
local critChance = 0
120+
local critMult = 2
121+
assert.are.equals(critChance, build.calcsTab.mainOutput.CritChance)
122+
assert.are.equals(critMult, build.calcsTab.mainOutput.CritMultiplier)
123+
124+
local averageHit = build.calcsTab.mainOutput.MainHand.AverageHit
125+
126+
-- 2: Add crits and validate crit damage
127+
build.configTab.input.customMods = "+10% to critical hit chance"
128+
build.configTab:BuildModList()
129+
runCallback("OnFrame")
130+
build.calcsTab:BuildOutput()
131+
runCallback("OnFrame")
132+
133+
local critChance = build.calcsTab.mainOutput.CritChance / 100
134+
local newAvgHit = (1 - critChance) * averageHit + critChance * averageHit * critMult
135+
assert.are.equals(newAvgHit, build.calcsTab.mainOutput.MainHand.AverageHit)
136+
end)
137+
138+
it("correctly calculates critical hit damage with static values", function()
139+
-- Setup: Create a 1 damage weapon with no crit chance, and strip enemy defenses
140+
build.itemsTab:CreateDisplayItemFromRaw([[
141+
New Item
142+
Heavy Bow
143+
Quality: 0
144+
-100% increased critical hit chance
145+
-100% increased physical damage
146+
adds 1 to 1 physical damage to attacks
147+
nearby enemies have 100% less armour
148+
nearby enemies have 100% less evasion
149+
]])
150+
build.itemsTab:AddDisplayItem()
151+
runCallback("OnFrame")
152+
build.calcsTab:BuildOutput()
153+
runCallback("OnFrame")
154+
155+
-- 1: Validate base damage = 1
156+
assert.are.equals(0, build.calcsTab.mainOutput.MainHand.CritChance)
157+
assert.are.equals(2, build.calcsTab.mainOutput.CritMultiplier)
158+
assert.are.equals(1, build.calcsTab.mainOutput.MainHand.AverageHit)
159+
160+
-- 2: Add crits and validate new damage = 1.1 (for a 10% crit chance)
161+
build.configTab.input.customMods = "+10% to critical hit chance"
162+
build.configTab:BuildModList()
163+
runCallback("OnFrame")
164+
build.calcsTab:BuildOutput()
165+
runCallback("OnFrame")
166+
167+
assert.are.equals(1.1, build.calcsTab.mainOutput.MainHand.AverageHit)
168+
end)
169+
170+
it("correctly adds damage with oracle forced outcome", function()
171+
-- Setup: Add weapon with no crit chance, and strip enemy defenses
172+
build.itemsTab:CreateDisplayItemFromRaw([[
173+
New Item
174+
Heavy Bow
175+
-100% increased Critical Hit Chance
176+
nearby enemies have 100% less armour
177+
nearby enemies have 100% less evasion
178+
]])
179+
build.itemsTab:AddDisplayItem()
180+
runCallback("OnFrame")
181+
build.calcsTab:BuildOutput()
182+
runCallback("OnFrame")
183+
184+
-- 1: Get base damage with no crits
185+
local critChance = 0.0
186+
local critMult = 2
187+
assert.are.equals(critChance, build.calcsTab.mainOutput.CritChance)
188+
assert.are.equals(critMult, build.calcsTab.mainOutput.CritMultiplier)
189+
190+
local averageHit = build.calcsTab.mainOutput.MainHand.AverageHit
191+
192+
-- 2: Add crits and forced outcome, and validate damage
193+
build.configTab.input.customMods = [[
194+
+10% to critical hit chance
195+
inevitable critical hits
196+
]]
197+
build.configTab:BuildModList()
198+
runCallback("OnFrame")
199+
build.calcsTab:BuildOutput()
200+
runCallback("OnFrame")
201+
202+
critChance = 0.1
203+
local nonCritChance = 1 - critChance
204+
205+
local critBonusMultiplier =
206+
1 * critChance
207+
+ .7 * nonCritChance * critChance
208+
+ .4 * nonCritChance * nonCritChance * critChance
209+
+ .1 * nonCritChance * nonCritChance * nonCritChance * critChance
210+
211+
-- When adding them as MORE mods, they get auto rounded after *100, so we need to do the same
212+
critBonusMultiplier = math.floor(critBonusMultiplier * 100 + 0.5)/100
213+
214+
local critBonus = critMult - 1
215+
critBonus = critBonus * critBonusMultiplier
216+
critMult = 1 + critBonus
217+
218+
local forcedExpectedAvgHit = averageHit * critMult
219+
assert.are.equals(forcedExpectedAvgHit, build.calcsTab.mainOutput.MainHand.AverageHit)
220+
end)
221+
222+
it("does not force critical hits when critical hit chance is zero", function()
223+
build.itemsTab:CreateDisplayItemFromRaw([[
224+
New Item
225+
Heavy Bow
226+
Quality: 0
227+
-100% increased critical hit chance
228+
-100% increased physical damage
229+
adds 1 to 1 physical damage to attacks
230+
nearby enemies have 100% less armour
231+
nearby enemies have 100% less evasion
232+
]])
233+
build.itemsTab:AddDisplayItem()
234+
runCallback("OnFrame")
235+
236+
build.configTab.input.customMods = "inevitable critical hits"
237+
build.configTab:BuildModList()
238+
runCallback("OnFrame")
239+
build.calcsTab:BuildOutput()
240+
runCallback("OnFrame")
241+
242+
assert.are.equals(0, build.calcsTab.mainOutput.MainHand.CritChance)
243+
assert.are.equals(1, build.calcsTab.mainOutput.MainHand.AverageHit)
244+
end)
245+
246+
it("correctly calculates forced outcome with bifurcated critical hits", function()
247+
build.itemsTab:CreateDisplayItemFromRaw([[
248+
New Item
249+
Heavy Bow
250+
Quality: 0
251+
-100% increased critical hit chance
252+
-100% increased physical damage
253+
adds 1 to 1 physical damage to attacks
254+
nearby enemies have 100% less armour
255+
nearby enemies have 100% less evasion
256+
]])
257+
build.itemsTab:AddDisplayItem()
258+
runCallback("OnFrame")
259+
260+
build.configTab.input.customMods = [[
261+
+10% to critical hit chance
262+
inevitable critical hits
263+
bifurcates critical hits
264+
]]
265+
build.configTab:BuildModList()
266+
runCallback("OnFrame")
267+
build.calcsTab:BuildOutput()
268+
runCallback("OnFrame")
269+
270+
local critChance = 0.1
271+
local failedStageChance = (1 - critChance) ^ 2
272+
local critBonusMultiplier = 2 * critChance * (
273+
1
274+
+ .7 * failedStageChance
275+
+ .4 * failedStageChance ^ 2
276+
+ .1 * failedStageChance ^ 3
277+
)
278+
critBonusMultiplier = math.floor(critBonusMultiplier * 100 + 0.5) / 100
279+
280+
assert.are.equals(100, build.calcsTab.mainOutput.MainHand.CritChance)
281+
local bifurcateChance = (critChance * 100) ^ 2 / ((1 - failedStageChance) * 100)
282+
assert.is_true(math.abs(bifurcateChance - build.calcsTab.mainOutput.MainHand.CritBifurcates) < 0.000001)
283+
assert.are.equals(1 + critBonusMultiplier, build.calcsTab.mainOutput.MainHand.AverageHit)
284+
end)
285+
end)

src/Data/ModCache.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5425,7 +5425,7 @@ c["Increases and Reductions to Minion Attack Speed also affect you"]={{[1]={flag
54255425
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}
54265426
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}
54275427
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}
5428-
c["Inevitable Critical Hits"]={nil,"Inevitable Critical Hits "}
5428+
c["Inevitable Critical Hits"]={{[1]={flags=0,keywordFlags=0,name="ForcedOutcome",type="FLAG",value=true}},nil}
54295429
c["Infinite Parry Range"]={nil,"Infinite Parry Range "}
54305430
c["Infinite Parry Range 50% increased Parried Debuff Duration"]={nil,"Infinite Parry Range 50% increased Parried Debuff Duration "}
54315431
c["Inflict Abyssal Wasting on Hit"]={nil,"Inflict Abyssal Wasting on Hit "}

src/Modules/CalcOffence.lua

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3484,6 +3484,7 @@ function calcs.offence(env, actor, activeSkill)
34843484
end
34853485

34863486
-- Calculate crit chance, crit multiplier, and their combined effect
3487+
local forcedOutcomeApplies = false
34873488
if skillModList:Flag(cfg, "NeverCrit") then
34883489
output.PreEffectiveCritChance = 0
34893490
output.CritChance = 0
@@ -3560,6 +3561,8 @@ function calcs.offence(env, actor, activeSkill)
35603561
output.PreEffectiveCritChance = output.CritChance
35613562
local preHitCheckCritChance = output.CritChance
35623563
if env.mode_effective then
3564+
-- 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
3565+
-- https://www.pathofexile.com/forum/view-thread/11707/filter-account-type/staff/page/10#p748465
35633566
output.CritChance = output.CritChance * output.AccuracyHitChance / 100
35643567
end
35653568
local preLuckyCritChance = output.CritChance
@@ -3571,6 +3574,34 @@ function calcs.offence(env, actor, activeSkill)
35713574
if env.mode_effective and skillModList:Flag(cfg, "BifurcateCrit") then
35723575
output.CritChance = (1 - (1 - output.CritChance / 100) ^ 2) * 100
35733576
end
3577+
local preForcedOutcomeCritChance = output.CritChance
3578+
if env.mode_effective and skillModList:Flag(cfg, "ForcedOutcome") and output.CritChance > 0 then
3579+
forcedOutcomeApplies = true
3580+
-- Lucky crits use their effective crit chance without an extra roll-down penalty.
3581+
-- Bifurcated crits roll twice per roll-down; only both rolls failing advances the penalty.
3582+
local critChance = output.CritChance / 100
3583+
local nonCritChance = 1 - critChance
3584+
3585+
local critBonusMultiplier =
3586+
1 * critChance + -- 100% crit damage, crit% of the time
3587+
0.7 * nonCritChance * critChance + -- 70% if we roll non-crit then a crit
3588+
0.4 * m_pow(nonCritChance, 2) * critChance + -- 40% if we roll two non-crit then a crit
3589+
0.1 * m_pow(nonCritChance, 3) * critChance -- 10% if we roll three non-crits then a crit
3590+
if skillModList:Flag(cfg, "BifurcateCrit") then
3591+
-- The terminating stage can crit twice, so scale its one-or-more-crit bonus
3592+
-- by the expected number of crits on that stage.
3593+
critBonusMultiplier = critBonusMultiplier * 2 * output.PreBifurcateCritChance / output.CritChance
3594+
output.CritBifurcates = output.PreBifurcateCritChance ^ 2 / output.CritChance
3595+
end
3596+
3597+
-- This gets rounded when used in damage logic, so round it ahead of time to make the breakdown accurate (and less ugly)
3598+
local lessCritBonus = round((1 - critBonusMultiplier) * -100.0, 0)
3599+
skillModList:NewMod("CritMultiplier", "MORE", lessCritBonus, "Tree:55135")
3600+
3601+
-- For the sake of any logic that depends on it, every hit is considered a crit
3602+
output.CritChance = 100
3603+
skillModList:NewMod("CritChance", "OVERRIDE", 100, "Tree:55135")
3604+
end
35743605
if breakdown and output.CritChance ~= baseCrit then
35753606
breakdown.CritChance = { }
35763607
local baseCritFromMainHandStr = baseCritFromMainHand and " from main weapon" or baseCritFromParentMainHand and " from parent main weapon" or ""
@@ -3605,7 +3636,11 @@ function calcs.offence(env, actor, activeSkill)
36053636
if env.mode_effective and skillModList:Flag(cfg, "BifurcateCrit") then
36063637
t_insert(breakdown.CritChance, "Critical Strike Bifurcates:")
36073638
t_insert(breakdown.CritChance, s_format("1 - (1 - %.4f) x (1 - %.4f)", preBifurcateCritChance / 100, preBifurcateCritChance / 100))
3608-
t_insert(breakdown.CritChance, s_format("= %.2f%%", output.CritChance))
3639+
t_insert(breakdown.CritChance, s_format("= %.2f%%", preForcedOutcomeCritChance))
3640+
end
3641+
if forcedOutcomeApplies then
3642+
t_insert(breakdown.CritChance, "Inevitable Critical Hits (Forced Outcome):")
3643+
t_insert(breakdown.CritChance, "= 100% ^8(override)")
36093644
end
36103645
end
36113646
end
@@ -3625,7 +3660,7 @@ function calcs.offence(env, actor, activeSkill)
36253660

36263661
output.PreEffectiveCritMultiplier = 1 + extraDamage
36273662
-- if crit bifurcates are enabled, roll for crit twice and add multiplier for each
3628-
if env.mode_effective and skillModList:Flag(cfg, "BifurcateCrit") then
3663+
if env.mode_effective and skillModList:Flag(cfg, "BifurcateCrit") and not forcedOutcomeApplies then
36293664
-- get crit chance and calculate odds of critting twice
36303665
local critChancePercentage = output.PreBifurcateCritChance
36313666
local bifurcateMultiChance = (critChancePercentage ^ 2) / 100
@@ -6015,4 +6050,4 @@ function calcs.offence(env, actor, activeSkill)
60156050
output.CullingDPS = output.CombinedDPS * (bestCull - 1)
60166051
output.ReservationDPS = output.CombinedDPS * (output.ReservationDpsMultiplier - 1)
60176052
output.CombinedDPS = output.CombinedDPS * bestCull * output.ReservationDpsMultiplier
6018-
end
6053+
end

src/Modules/ModParser.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3359,6 +3359,7 @@ local specialModList = {
33593359
mod("EnemyModifier", "LIST", { mod = mod("LightningExposure", "BASE", 20) }, { type = "ActorCondition", actor = "enemy", var = "EnemyInPresence" }),
33603360
},
33613361
-- Druid -- Oracle
3362+
["inevitable critical hits"] = { flag("ForcedOutcome") },
33623363
["walk the paths not taken"] = { },
33633364
["gain the benefits of bonded modifiers on runes and idols"] = {
33643365
flag("Condition:CanUseBondedModifiers"),

0 commit comments

Comments
 (0)