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/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index f78d34f7d31..12974b0b783 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 @@ -2958,7 +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 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:") @@ -2967,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)) @@ -2974,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:") @@ -3006,6 +3020,42 @@ 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 bifurcateUseChance = 1 + -- Guaranteed crit uses do not roll crit chance and therefore cannot bifurcate + if skillModList:Flag(skillCfg, "Every3UseCrit") then + bifurcateUseChance = bifurcateUseChance * 2 / 3 + end + if skillModList:Flag(skillCfg, "Every5UseCrit") then + 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 + if breakdown then + breakdown.CritBifurcates = { + s_format("%.2f%% ^8(pre-bifurcate crit chance)", critChancePercentage), + s_format("x %.2f%%", critChancePercentage), + } + 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 + 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 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") },