From 29eceb67f3bad8f6538a1d6b113c42ef5d194d62 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:46:43 +0300 Subject: [PATCH 1/4] Port support for spell bifurcated crits --- spec/System/TestSkills_spec.lua | 86 +++++++++++++++++++++++++++++++++ src/Modules/CalcOffence.lua | 45 ++++++++++++++++- src/Modules/CalcSections.lua | 4 ++ src/Modules/ModParser.lua | 1 + 4 files changed, 135 insertions(+), 1 deletion(-) diff --git a/spec/System/TestSkills_spec.lua b/spec/System/TestSkills_spec.lua index 831ac254660..25e788b7d8b 100644 --- a/spec/System/TestSkills_spec.lua +++ b/spec/System/TestSkills_spec.lua @@ -150,4 +150,90 @@ describe("TestAttacks", function() assert.True(preAdrenalineMaxStages < build.calcsTab.mainEnv.player.activeSkillList[1].skillModList:Sum("BASE", nil, "Multiplier:BlightMaxStages")) end) + local function setupBifurcate(socketGroup, bifurcate, lucky, turboLucky, noCritMulti) + newBuild() + build.itemsTab:CreateDisplayItemFromRaw([[ + New Item + Imbued Wand + Quality: 0 + 100% reduced lightning damage + adds 1 to 1 physical damage to spells + nearby enemies have 100% less armour + ]]) + build.itemsTab:AddDisplayItem() + runCallback("OnFrame") + build.skillsTab:PasteSocketGroup(socketGroup) + runCallback("OnFrame") + + local customCrit = 50 - 6 -- spark 6% base crit + build.configTab.input.customMods = string.format("+%d%% to critical hit chance\n", customCrit) + .. (bifurcate and "spell critical strike chance bifurcates\n" or "") + .. (lucky and "your critical strike chance is lucky\n" or "") + .. (turboLucky and "your lucky or unlucky effects use the best or worst from three rolls instead of two\n" or "") + .. (noCritMulti and "" or "your critical strike multiplier is 1000000%\n") + build.configTab:BuildModList() + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + return build.calcsTab.mainOutput + end + local function closeEnough(a, b, epsilon) + if not epsilon then epsilon = 10 ^ -9 end + return math.abs(a - b) < epsilon + end + it("correctly calculates bifurcated critical hit damage", function() + local normalOutput = setupBifurcate("Spark 1/0 1") + assert.are.equals(50, normalOutput.CritChance) + assert.are.equals(10000, normalOutput.CritMultiplier) + assert.are.equals(10001, normalOutput.AverageHit) + + local garukhanOutput = setupBifurcate("Spark 1/0 1", true) + assert.are.equals(50, garukhanOutput.PreBifurcateCritChance) + assert.are.equals(75, garukhanOutput.CritChance) + assert.is_true(closeEnough(1 + 1 / 3, garukhanOutput.CritBifurcates)) + assert.are.equals(20000, garukhanOutput.AverageHit) + end) + it("correctly calculates bifurcated for every nth crits spells", function() + local normalOutput = setupBifurcate("Spark 1/0 1", nil, nil, nil, true) + assert.are.equals(1.5, normalOutput.CritMultiplier) + + local garukhanOutput = setupBifurcate("Spark 1/0 1\nAssassin's Mark 1/0 1", nil, nil, nil, true) + assert.are.equals(1.8, garukhanOutput.CritMultiplier) + + local garukhanOutput = setupBifurcate("Spark 1/0 1\nAssassin's Mark 1/0 1", true, nil, nil, true) + assert.are.equals(2.07, floor(garukhanOutput.CritMultiplier, 2)) + end) + it("multiplies assassin's mark ", function() + local normalOutput = setupBifurcate("Lightning Tendrils 1/0 1") + assert.are.equals(200 / 3, normalOutput.CritChance) + assert.are.equals(10000, normalOutput.CritMultiplier) + + local garukhanOutput = setupBifurcate("Lightning Tendrils 1/0 1", true) + assert.are.equals(50, garukhanOutput.PreBifurcateCritChance) + assert.is_true(closeEnough(100 / 3 + (200 / 3) * 0.75, garukhanOutput.CritChance, 10 ^ -6)) + assert.are.equals(1.2, garukhanOutput.CritBifurcates) + local garukhanOutput = setupBifurcate("Lightning Tendrils of Eccentricity 1/0 1", true) + assert.are.equals(50, garukhanOutput.PreBifurcateCritChance) + assert.are.equals(80, garukhanOutput.CritChance) + assert.are.equals(1.25, garukhanOutput.CritBifurcates) + end) + it("correctly calculates lucky bifurcated critical hit damage", function() + local normalOutput = setupBifurcate("Spark 1/0 1", false, true) + assert.are.equals(75, normalOutput.CritChance) + assert.are.equals(10000, normalOutput.CritMultiplier) + assert.are.equals(15000.5, normalOutput.AverageHit) + + local garukhanOutput = setupBifurcate("Spark 1/0 1", true, true) + assert.are.equals(75, garukhanOutput.PreBifurcateCritChance) + assert.are.equals(93.75, garukhanOutput.CritChance) + assert.are.equals(garukhanOutput.CritBifurcates, 16 / 10) + assert.are.equals(29999, garukhanOutput.AverageHit) + + local garukhanOutput = setupBifurcate("Spark 1/0 1", true, true, true) + assert.are.equals(87.5, garukhanOutput.PreBifurcateCritChance) + assert.are.equals(98.4375, garukhanOutput.CritChance) + assert.are.equals(1 + 0.875 * 0.875 / (0.984375), garukhanOutput.CritBifurcates) + assert.is_true(math.abs(34998.5 - garukhanOutput.AverageHit) < 0.01) + end) end) \ No newline at end of file diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index f78d34f7d31..99adc79f619 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -2893,6 +2893,7 @@ function calcs.offence(env, actor, activeSkill) if critOverride == 100 then output.PreEffectiveCritChance = 100 + output.PreBifurcateCritChance = 100 output.CritChance = 100 else local base = 0 @@ -2927,8 +2928,13 @@ function calcs.offence(env, actor, activeSkill) output.CritChance = (1 - (1 - output.CritChance / 100) ^ (critRolls + 1)) * 100 end end + output.PreBifurcateCritChance = output.CritChance + local preBifurcateCritChance = output.CritChance + if env.mode_effective and skillModList:Flag(cfg, "BifurcateCrit") then + output.CritChance = (1 - (1 - output.CritChance / 100) ^ 2) * 100 + end local preHitCheckCritChance = output.CritChance - local preSkillUseCritChance= output.CritChance + local preSkillUseCritChance = output.CritChance if env.mode_effective then if skillModList:Flag(skillCfg, "Every3UseCrit") then output.CritChance = (2 * output.CritChance + 100) / 3 @@ -2968,6 +2974,11 @@ function calcs.offence(env, actor, activeSkill) t_insert(breakdown.CritChance, s_format("1 - (1 - %.4f)^ %d", preLuckyCritChance / 100, critRolls + 1)) end end + if 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%%", preSkillUseCritChance)) + end if skillModList:Flag(skillCfg, "Every3UseCrit") then t_insert(breakdown.CritChance, s_format("+ %.2f%% ^8(crit every 3rd use)", (2 * preSkillUseCritChance + 100) / 3 - preSkillUseCritChance)) end @@ -3006,6 +3017,37 @@ function calcs.offence(env, actor, activeSkill) } end end + -- if crit bifurcates are enabled, roll for crit twice and add multiplier for each + local critOverride = skillModList:Override(cfg, "CritChance") + if env.mode_effective and skillModList:Flag(cfg, "BifurcateCrit") and output.PreBifurcateCritChance and not (critOverride == 100) then + -- get crit chance and calculate odds of critting twice + local critChancePercentage = output.PreBifurcateCritChance + local bifurcateMultiChance = (critChancePercentage ^ 2) / 100 + local effectiveCritChance = output.CritChance + local conditionalBifurcateChance = effectiveCritChance > 0 and bifurcateMultiChance / effectiveCritChance or 0 + -- scale damage bonus chance to account for guaranteed crit + -- not being able to benefit from it + if skillModList:Flag(skillCfg, "Every3UseCrit") then + conditionalBifurcateChance = conditionalBifurcateChance * 2 / 3 + end + if skillModList:Flag(skillCfg, "Every5UseCrit") then + conditionalBifurcateChance = conditionalBifurcateChance * 4 / 5 + end + output.CritBifurcates = 1 + conditionalBifurcateChance + local damageBonus = extraDamage + local bifurcatedBonus = conditionalBifurcateChance * extraDamage + if breakdown then + breakdown.CritBifurcates = { + s_format("%.2f%% ^8(pre-bifurcate crit chance)", critChancePercentage), + s_format("x %.2f%%", critChancePercentage), + s_format("= %.2f%% ^8(chance both crit rolls succeed)", bifurcateMultiChance), + s_format("/ %.2f%% ^8(chance at least one crit roll succeeds)", effectiveCritChance), + s_format("= %.2f ^8(crit Bifurcates effect)", 1 + conditionalBifurcateChance), + } + end + extraDamage = damageBonus + bifurcatedBonus + skillModList:NewMod("CritMultiplier", "MORE", floor(conditionalBifurcateChance * 100, 2), "Bifurcated Crit Damage Bonus", ModFlag.Hit) + end output.CritMultiplier = 1 + m_max(0, extraDamage) end local critChancePercentage = output.CritChance / 100 @@ -3708,6 +3750,7 @@ function calcs.offence(env, actor, activeSkill) combineStat("PreEffectiveCritChance", "AVERAGE") combineStat("CritChance", "AVERAGE") combineStat("CritMultiplier", "AVERAGE") + combineStat("CritBifurcates", "AVERAGE") combineStat("AverageDamage", "DPS") combineStat("PvpAverageDamage", "DPS") combineStat("TotalDPS", "DPS") diff --git a/src/Modules/CalcSections.lua b/src/Modules/CalcSections.lua index 7ed5469a942..c4eff1c225c 100644 --- a/src/Modules/CalcSections.lua +++ b/src/Modules/CalcSections.lua @@ -527,6 +527,10 @@ return { { label = "Player modifiers", modName = "CritMultiplier", cfg = "skill" }, { label = "Enemy modifiers", modName = "SelfCritMultiplier", enemy = true }, }, }, + { label = "Crit Bifurcates", notFlag = "attack", haveOutput = "CritBifurcates", { format = "x {2:output:CritBifurcates}", + { breakdown = "CritBifurcates" }, + { label = "Player modifiers", modName = "BifurcateCrit", cfg = "skill" }, + }, }, { label = "Crit Effect Mod", notFlag = "attack", { format = "x {3:output:CritEffect}", { breakdown = "CritEffect" }, }, }, -- Main Hand { label = "MH Inc. Crit Chance", bgCol = colorCodes.MAINHANDBG, flag = "weapon1Attack", { format = "{0:mod:1,2}%", diff --git a/src/Modules/ModParser.lua b/src/Modules/ModParser.lua index 2438ab4ec87..5a9fdb4bad5 100644 --- a/src/Modules/ModParser.lua +++ b/src/Modules/ModParser.lua @@ -3386,6 +3386,7 @@ local specialModList = { ["minion critical strikes do not deal extra damage"] = { mod("MinionModifier", "LIST", { mod = flag("NoCritMultiplier") }) }, ["lightning damage with non%-critical strikes is lucky"] = { flag("LightningNoCritLucky") }, ["your damage with critical strikes is lucky"] = { flag("CritLucky") }, + ["spell critical strike chance bifurcates"] = { flag("BifurcateCrit", nil, ModFlag.Spell) }, ["critical strikes deal no damage"] = { mod("Damage", "MORE", -100, { type = "Condition", var = "CriticalStrike" }) }, ["critical strike chance is increased by uncapped lightning resistance"] = { flag("CritChanceIncreasedByUncappedLightningRes") }, ["critical strike chance is increased by lightning resistance"] = { flag("CritChanceIncreasedByLightningRes") }, From c44406971592fabc99d61c0fc9e948f310f9c7f4 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:12:31 +0300 Subject: [PATCH 2/4] Fix breakdown not showing and tidy up code --- src/Modules/CalcOffence.lua | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 99adc79f619..a8456ae3034 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -2964,6 +2964,11 @@ function calcs.offence(env, actor, activeSkill) local overCap = preCapCritChance - 100 t_insert(breakdown.CritChance, s_format("Crit is overcapped by %.2f%% (%d%% increased Critical Strike Chance)", overCap, overCap / more / (baseCrit + base) * 100)) end + if 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%%", preSkillUseCritChance)) + end if env.mode_effective and (critRolls ~= 0 or skillModList:Flag(skillCfg, "Every3UseCrit") or skillModList:Flag(skillCfg, "Every5UseCrit")) then if critRolls ~= 0 then if skillModList:Flag(skillCfg, "Unexciting") then @@ -2974,11 +2979,6 @@ function calcs.offence(env, actor, activeSkill) t_insert(breakdown.CritChance, s_format("1 - (1 - %.4f)^ %d", preLuckyCritChance / 100, critRolls + 1)) end end - if 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%%", preSkillUseCritChance)) - end if skillModList:Flag(skillCfg, "Every3UseCrit") then t_insert(breakdown.CritChance, s_format("+ %.2f%% ^8(crit every 3rd use)", (2 * preSkillUseCritChance + 100) / 3 - preSkillUseCritChance)) end @@ -3046,6 +3046,7 @@ function calcs.offence(env, actor, activeSkill) } end extraDamage = damageBonus + bifurcatedBonus + -- mod doesn't affect output and is purely descriptive skillModList:NewMod("CritMultiplier", "MORE", floor(conditionalBifurcateChance * 100, 2), "Bifurcated Crit Damage Bonus", ModFlag.Hit) end output.CritMultiplier = 1 + m_max(0, extraDamage) @@ -3750,7 +3751,6 @@ function calcs.offence(env, actor, activeSkill) combineStat("PreEffectiveCritChance", "AVERAGE") combineStat("CritChance", "AVERAGE") combineStat("CritMultiplier", "AVERAGE") - combineStat("CritBifurcates", "AVERAGE") combineStat("AverageDamage", "DPS") combineStat("PvpAverageDamage", "DPS") combineStat("TotalDPS", "DPS") From 497d3c9112c4087fbe629634e3c437a89375350e Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:58:37 +0300 Subject: [PATCH 3/4] Fix breakdown ordering --- src/Modules/CalcOffence.lua | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index a8456ae3034..f0769e73cf8 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -2964,12 +2964,7 @@ function calcs.offence(env, actor, activeSkill) local overCap = preCapCritChance - 100 t_insert(breakdown.CritChance, s_format("Crit is overcapped by %.2f%% (%d%% increased Critical Strike Chance)", overCap, overCap / more / (baseCrit + base) * 100)) end - if 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%%", preSkillUseCritChance)) - end - if env.mode_effective and (critRolls ~= 0 or skillModList:Flag(skillCfg, "Every3UseCrit") or skillModList:Flag(skillCfg, "Every5UseCrit")) then + if env.mode_effective then if critRolls ~= 0 then if skillModList:Flag(skillCfg, "Unexciting") then t_insert(breakdown.CritChance, "Crit Chance is Unexciting:") @@ -2978,6 +2973,12 @@ function calcs.offence(env, actor, activeSkill) t_insert(breakdown.CritChance, "Crit Chance is Lucky:") t_insert(breakdown.CritChance, s_format("1 - (1 - %.4f)^ %d", preLuckyCritChance / 100, critRolls + 1)) end + t_insert(breakdown.CritChance, s_format("= %.2f%%", preBifurcateCritChance)) + end + if 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%%", preSkillUseCritChance)) end if skillModList:Flag(skillCfg, "Every3UseCrit") then t_insert(breakdown.CritChance, s_format("+ %.2f%% ^8(crit every 3rd use)", (2 * preSkillUseCritChance + 100) / 3 - preSkillUseCritChance)) @@ -2985,7 +2986,9 @@ function calcs.offence(env, actor, activeSkill) if skillModList:Flag(skillCfg, "Every5UseCrit") then t_insert(breakdown.CritChance, s_format("+ %.2f%% ^8(crit every 5th use)", (4 * preSkillUseCritChance + 100) / 5 - preSkillUseCritChance)) end - t_insert(breakdown.CritChance, s_format("= %.2f%%", preHitCheckCritChance)) + if skillModList:Flag(skillCfg, "Every3UseCrit") or skillModList:Flag(skillCfg, "Every5UseCrit") then + t_insert(breakdown.CritChance, s_format("= %.2f%%", preHitCheckCritChance)) + end end if env.mode_effective and output.AccuracyHitChance < 100 then t_insert(breakdown.CritChance, "Crit confirmation roll:") From de3ca07b103c6ca88e3defe16b52afabee3e4dc3 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Tue, 21 Jul 2026 00:37:23 +1000 Subject: [PATCH 4/4] Fix breakdown + move tests --- spec/System/TestBifurcatedCrit_spec.lua | 90 +++++++++++++++++++++++++ spec/System/TestSkills_spec.lua | 86 ----------------------- src/Modules/CalcOffence.lua | 20 +++--- 3 files changed, 102 insertions(+), 94 deletions(-) create mode 100644 spec/System/TestBifurcatedCrit_spec.lua diff --git a/spec/System/TestBifurcatedCrit_spec.lua b/spec/System/TestBifurcatedCrit_spec.lua new file mode 100644 index 00000000000..c99d2126991 --- /dev/null +++ b/spec/System/TestBifurcatedCrit_spec.lua @@ -0,0 +1,90 @@ +describe("Bifurcated critical strikes", function() + local function setupBifurcate(socketGroup, bifurcate, lucky, extremeLuck, useDefaultCritMultiplier) + newBuild() + build.itemsTab:CreateDisplayItemFromRaw([[ + New Item + Imbued Wand + Quality: 0 + 100% reduced lightning damage + adds 1 to 1 physical damage to spells + nearby enemies have 100% less armour + ]]) + build.itemsTab:AddDisplayItem() + runCallback("OnFrame") + build.skillsTab:PasteSocketGroup(socketGroup) + runCallback("OnFrame") + + build.configTab.input.customMods = "+44% to critical hit chance\n" + .. (bifurcate and "spell critical strike chance bifurcates\n" or "") + .. (lucky and "your critical strike chance is lucky\n" or "") + .. (extremeLuck and "your lucky or unlucky effects use the best or worst from three rolls instead of two\n" or "") + .. (useDefaultCritMultiplier and "" or "your critical strike multiplier is 1000000%\n") + build.configTab:BuildModList() + runCallback("OnFrame") + + return build.calcsTab.mainOutput + end + + it("calculates bifurcated critical hit damage", function() + local normalOutput = setupBifurcate("Spark 1/0 1") + assert.are.equals(50, normalOutput.CritChance) + assert.are.equals(10000, normalOutput.CritMultiplier) + assert.are.equals(10001, normalOutput.AverageHit) + + local bifurcateOutput = setupBifurcate("Spark 1/0 1", true) + assert.are.equals(50, bifurcateOutput.PreBifurcateCritChance) + assert.are.equals(75, bifurcateOutput.CritChance) + assert.are.near(1 + 1 / 3, bifurcateOutput.CritBifurcates, 10 ^ -9) + assert.are.equals(20000, bifurcateOutput.AverageHit) + + local mysticalInfusion = build.spec.nodes[1945] + mysticalInfusion.alloc = true + build.spec.allocNodes[mysticalInfusion.id] = mysticalInfusion + build.configTab.input.customMods = "+44% to critical hit chance\nyour critical strike multiplier is 1000000%\n" + build.configTab:BuildModList() + build.buildFlag = true + runCallback("OnFrame") + assert.are.equals(35, build.calcsTab.mainOutput.PreBifurcateCritChance) + assert.are.near(57.75, build.calcsTab.mainOutput.CritChance, 10 ^ -9) + end) + + it("accounts for guaranteed critical strikes", function() + local normalOutput = setupBifurcate("Spark 1/0 1", nil, nil, nil, true) + assert.are.equals(1.5, normalOutput.CritMultiplier) + + local markedOutput = setupBifurcate("Spark 1/0 1\nAssassin's Mark 1/0 1", nil, nil, nil, true) + assert.are.equals(1.8, markedOutput.CritMultiplier) + + local bifurcateOutput = setupBifurcate("Spark 1/0 1\nAssassin's Mark 1/0 1", true, nil, nil, true) + assert.are.equals(2.07, floor(bifurcateOutput.CritMultiplier, 2)) + + local tendrilsOutput = setupBifurcate("Lightning Tendrils 1/0 1", true) + assert.are.equals(50, tendrilsOutput.PreBifurcateCritChance) + assert.are.near(100 / 3 + (200 / 3) * 0.75, tendrilsOutput.CritChance, 10 ^ -6) + assert.are.equals(1.2, tendrilsOutput.CritBifurcates) + + local eccentricityOutput = setupBifurcate("Lightning Tendrils of Eccentricity 1/0 1", true) + assert.are.equals(50, eccentricityOutput.PreBifurcateCritChance) + assert.are.equals(80, eccentricityOutput.CritChance) + assert.are.equals(1.25, eccentricityOutput.CritBifurcates) + end) + + it("applies lucky rolls independently", function() + local luckyOutput = setupBifurcate("Spark 1/0 1", false, true) + assert.are.equals(75, luckyOutput.CritChance) + assert.are.equals(10000, luckyOutput.CritMultiplier) + assert.are.equals(15000.5, luckyOutput.AverageHit) + + local bifurcateOutput = setupBifurcate("Spark 1/0 1", true, true) + assert.are.equals(75, bifurcateOutput.PreBifurcateCritChance) + assert.are.equals(93.75, bifurcateOutput.CritChance) + assert.are.equals(1.6, bifurcateOutput.CritBifurcates) + assert.are.equals(29999, bifurcateOutput.AverageHit) + + local extremeLuckOutput = setupBifurcate("Spark 1/0 1", true, true, true) + assert.are.equals(87.5, extremeLuckOutput.PreBifurcateCritChance) + assert.are.equals(98.4375, extremeLuckOutput.CritChance) + assert.are.equals(1 + 0.875 ^ 2 / 0.984375, extremeLuckOutput.CritBifurcates) + assert.are.near(34998.5, extremeLuckOutput.AverageHit, 0.01) + end) +end) diff --git a/spec/System/TestSkills_spec.lua b/spec/System/TestSkills_spec.lua index 25e788b7d8b..831ac254660 100644 --- a/spec/System/TestSkills_spec.lua +++ b/spec/System/TestSkills_spec.lua @@ -150,90 +150,4 @@ describe("TestAttacks", function() assert.True(preAdrenalineMaxStages < build.calcsTab.mainEnv.player.activeSkillList[1].skillModList:Sum("BASE", nil, "Multiplier:BlightMaxStages")) end) - local function setupBifurcate(socketGroup, bifurcate, lucky, turboLucky, noCritMulti) - newBuild() - build.itemsTab:CreateDisplayItemFromRaw([[ - New Item - Imbued Wand - Quality: 0 - 100% reduced lightning damage - adds 1 to 1 physical damage to spells - nearby enemies have 100% less armour - ]]) - build.itemsTab:AddDisplayItem() - runCallback("OnFrame") - build.skillsTab:PasteSocketGroup(socketGroup) - runCallback("OnFrame") - - local customCrit = 50 - 6 -- spark 6% base crit - build.configTab.input.customMods = string.format("+%d%% to critical hit chance\n", customCrit) - .. (bifurcate and "spell critical strike chance bifurcates\n" or "") - .. (lucky and "your critical strike chance is lucky\n" or "") - .. (turboLucky and "your lucky or unlucky effects use the best or worst from three rolls instead of two\n" or "") - .. (noCritMulti and "" or "your critical strike multiplier is 1000000%\n") - build.configTab:BuildModList() - runCallback("OnFrame") - build.calcsTab:BuildOutput() - runCallback("OnFrame") - - return build.calcsTab.mainOutput - end - local function closeEnough(a, b, epsilon) - if not epsilon then epsilon = 10 ^ -9 end - return math.abs(a - b) < epsilon - end - it("correctly calculates bifurcated critical hit damage", function() - local normalOutput = setupBifurcate("Spark 1/0 1") - assert.are.equals(50, normalOutput.CritChance) - assert.are.equals(10000, normalOutput.CritMultiplier) - assert.are.equals(10001, normalOutput.AverageHit) - - local garukhanOutput = setupBifurcate("Spark 1/0 1", true) - assert.are.equals(50, garukhanOutput.PreBifurcateCritChance) - assert.are.equals(75, garukhanOutput.CritChance) - assert.is_true(closeEnough(1 + 1 / 3, garukhanOutput.CritBifurcates)) - assert.are.equals(20000, garukhanOutput.AverageHit) - end) - it("correctly calculates bifurcated for every nth crits spells", function() - local normalOutput = setupBifurcate("Spark 1/0 1", nil, nil, nil, true) - assert.are.equals(1.5, normalOutput.CritMultiplier) - - local garukhanOutput = setupBifurcate("Spark 1/0 1\nAssassin's Mark 1/0 1", nil, nil, nil, true) - assert.are.equals(1.8, garukhanOutput.CritMultiplier) - - local garukhanOutput = setupBifurcate("Spark 1/0 1\nAssassin's Mark 1/0 1", true, nil, nil, true) - assert.are.equals(2.07, floor(garukhanOutput.CritMultiplier, 2)) - end) - it("multiplies assassin's mark ", function() - local normalOutput = setupBifurcate("Lightning Tendrils 1/0 1") - assert.are.equals(200 / 3, normalOutput.CritChance) - assert.are.equals(10000, normalOutput.CritMultiplier) - - local garukhanOutput = setupBifurcate("Lightning Tendrils 1/0 1", true) - assert.are.equals(50, garukhanOutput.PreBifurcateCritChance) - assert.is_true(closeEnough(100 / 3 + (200 / 3) * 0.75, garukhanOutput.CritChance, 10 ^ -6)) - assert.are.equals(1.2, garukhanOutput.CritBifurcates) - local garukhanOutput = setupBifurcate("Lightning Tendrils of Eccentricity 1/0 1", true) - assert.are.equals(50, garukhanOutput.PreBifurcateCritChance) - assert.are.equals(80, garukhanOutput.CritChance) - assert.are.equals(1.25, garukhanOutput.CritBifurcates) - end) - it("correctly calculates lucky bifurcated critical hit damage", function() - local normalOutput = setupBifurcate("Spark 1/0 1", false, true) - assert.are.equals(75, normalOutput.CritChance) - assert.are.equals(10000, normalOutput.CritMultiplier) - assert.are.equals(15000.5, normalOutput.AverageHit) - - local garukhanOutput = setupBifurcate("Spark 1/0 1", true, true) - assert.are.equals(75, garukhanOutput.PreBifurcateCritChance) - assert.are.equals(93.75, garukhanOutput.CritChance) - assert.are.equals(garukhanOutput.CritBifurcates, 16 / 10) - assert.are.equals(29999, garukhanOutput.AverageHit) - - local garukhanOutput = setupBifurcate("Spark 1/0 1", true, true, true) - assert.are.equals(87.5, garukhanOutput.PreBifurcateCritChance) - assert.are.equals(98.4375, garukhanOutput.CritChance) - assert.are.equals(1 + 0.875 * 0.875 / (0.984375), garukhanOutput.CritBifurcates) - assert.is_true(math.abs(34998.5 - garukhanOutput.AverageHit) < 0.01) - end) end) \ No newline at end of file diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index f0769e73cf8..12974b0b783 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -3027,15 +3027,16 @@ function calcs.offence(env, actor, activeSkill) local critChancePercentage = output.PreBifurcateCritChance local bifurcateMultiChance = (critChancePercentage ^ 2) / 100 local effectiveCritChance = output.CritChance - local conditionalBifurcateChance = effectiveCritChance > 0 and bifurcateMultiChance / effectiveCritChance or 0 - -- scale damage bonus chance to account for guaranteed crit - -- not being able to benefit from it + local bifurcateUseChance = 1 + -- Guaranteed crit uses do not roll crit chance and therefore cannot bifurcate if skillModList:Flag(skillCfg, "Every3UseCrit") then - conditionalBifurcateChance = conditionalBifurcateChance * 2 / 3 + bifurcateUseChance = bifurcateUseChance * 2 / 3 end if skillModList:Flag(skillCfg, "Every5UseCrit") then - conditionalBifurcateChance = conditionalBifurcateChance * 4 / 5 + bifurcateUseChance = bifurcateUseChance * 4 / 5 end + bifurcateMultiChance = bifurcateMultiChance * bifurcateUseChance + local conditionalBifurcateChance = effectiveCritChance > 0 and bifurcateMultiChance / effectiveCritChance or 0 output.CritBifurcates = 1 + conditionalBifurcateChance local damageBonus = extraDamage local bifurcatedBonus = conditionalBifurcateChance * extraDamage @@ -3043,10 +3044,13 @@ function calcs.offence(env, actor, activeSkill) breakdown.CritBifurcates = { s_format("%.2f%% ^8(pre-bifurcate crit chance)", critChancePercentage), s_format("x %.2f%%", critChancePercentage), - s_format("= %.2f%% ^8(chance both crit rolls succeed)", bifurcateMultiChance), - s_format("/ %.2f%% ^8(chance at least one crit roll succeeds)", effectiveCritChance), - s_format("= %.2f ^8(crit Bifurcates effect)", 1 + conditionalBifurcateChance), } + if bifurcateUseChance < 1 then + t_insert(breakdown.CritBifurcates, s_format("x %.2f%% ^8(uses that can bifurcate)", bifurcateUseChance * 100)) + end + t_insert(breakdown.CritBifurcates, s_format("= %.2f%% ^8(chance both crit rolls succeed)", bifurcateMultiChance)) + t_insert(breakdown.CritBifurcates, s_format("/ %.2f%% ^8(effective crit chance)", effectiveCritChance)) + t_insert(breakdown.CritBifurcates, s_format("= %.2f ^8(crit Bifurcates effect)", 1 + conditionalBifurcateChance)) end extraDamage = damageBonus + bifurcatedBonus -- mod doesn't affect output and is purely descriptive