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/2] 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/2] 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")