diff --git a/spec/System/TestSkills_spec.lua b/spec/System/TestSkills_spec.lua index 345187f53a..3dad84ceea 100644 --- a/spec/System/TestSkills_spec.lua +++ b/spec/System/TestSkills_spec.lua @@ -151,6 +151,81 @@ describe("TestAttacks", function() assert.True(preAdrenalineMaxStages < build.calcsTab.mainEnv.player.activeSkillList[1].skillModList:Sum("BASE", nil, "Multiplier:BlightMaxStages")) end) + it("calculates Wintertide Brand average damage for attached brands and Wintertide's End", function() + local function getAverageDamageMultiplier() + for _, mod in ipairs(build.calcsTab.mainEnv.player.mainSkill.skillModList) do + if mod.source == "Wintertide Brand Average Multiplier" then + return mod.value + end + end + end + + build.skillsTab:PasteSocketGroup("Wintertide Brand 20/0 1\n") + runCallback("OnFrame") + local mainSocketGroup = build.skillsTab.socketGroupList[build.mainSocketGroup] + local srcInstance = mainSocketGroup.displaySkillList[mainSocketGroup.mainActiveSkill].activeEffect.srcInstance + srcInstance.skillPart = 1 + build.modFlag = true + build.buildFlag = true + runCallback("OnFrame") + + assert.is_true(build.configTab.varControls.BrandsAttachedToEnemy.shown()) + assert.are.equals(8, build.calcsTab.mainOutput.BrandTicks) + assert.are.near(2, build.calcsTab.mainOutput.DurationTertiary, 0.02) + assert.are.equals(20, build.calcsTab.mainEnv.player.mainSkill.skillModList:Sum("BASE", build.calcsTab.mainEnv.player.mainSkill.skillCfg, "Multiplier:WintertideBrandMaxStages")) + assert.are.equals("Average Damage for 2 attached Brands", build.calcsTab.mainEnv.player.mainSkill.infoMessage) + assert.are.near(500, getAverageDamageMultiplier(), 10 ^ -9) + + build.configTab.input.BrandsAttachedToEnemy = 1 + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.are.equals("Average Damage for 1 attached Brand", build.calcsTab.mainEnv.player.mainSkill.infoMessage) + assert.are.near(330, getAverageDamageMultiplier(), 10 ^ -9) + + build.configTab.input.BrandsAttachedToEnemy = nil + build.configTab.input.customMods = "You can have an additional Brand Attached to an Enemy" + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.are.equals("Average Damage for 3 attached Brands", build.calcsTab.mainEnv.player.mainSkill.infoMessage) + assert.are.near(670, getAverageDamageMultiplier(), 10 ^ -9) + + build.configTab.input.customMods = "400% increased Skill Effect Duration" + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.are.equals(40, build.calcsTab.mainOutput.BrandTicks) + assert.are.near(1190, getAverageDamageMultiplier(), 10 ^ -9) + end) + + it("multiplies Brand DPS by the attached Brand count", function() + build.skillsTab:PasteSocketGroup("Armageddon Brand 20/0 1\n") + runCallback("OnFrame") + + local singleBrandDPS = build.calcsTab.mainOutput.TotalDPS + build.configTab.input.customMods = "You can have an additional Brand Attached to an Enemy" + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.are.near(singleBrandDPS * 2, build.calcsTab.mainOutput.TotalDPS, 10 ^ -9) + assert.are.equals("DPS for 2 attached Brands", build.calcsTab.mainEnv.player.mainSkill.infoMessage) + end) + + it("multiplies non-stacking Brand damage over time by the attached Brand count", function() + build.skillsTab:PasteSocketGroup("Wintertide Brand 20/0 1\n") + build.configTab.input.BrandsAttachedToEnemy = 1 + build.configTab:BuildModList() + runCallback("OnFrame") + + local singleBrandDPS = build.calcsTab.mainOutput.TotalDot + build.configTab.input.BrandsAttachedToEnemy = nil + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.are.near(singleBrandDPS * 2, build.calcsTab.mainOutput.TotalDot, 10 ^ -9) + end) + it("averages inverted elemental resistance after penetration", function() build.skillsTab:PasteSocketGroup("Fireball 20/0 1") build.configTab.input.enemyIsBoss = "None" diff --git a/spec/System/TestTriggers_spec.lua b/spec/System/TestTriggers_spec.lua index 0f8c9b54ac..c4ca6d2f12 100644 --- a/spec/System/TestTriggers_spec.lua +++ b/spec/System/TestTriggers_spec.lua @@ -1189,6 +1189,25 @@ describe("TestTriggers", function() assert.True(build.calcsTab.mainOutput.SkillTriggerRate ~= nil) end) + it("multiplies Arcanist Brand trigger rate by the attached Brand count", function() + build.skillsTab:PasteSocketGroup("Arcanist Brand 20/0 1\nFireball 20/0 1\n") + runCallback("OnFrame") + + local mainSocketGroup = build.skillsTab.socketGroupList[build.mainSocketGroup] + mainSocketGroup.mainActiveSkill = 2 + build.modFlag = true + build.buildFlag = true + runCallback("OnFrame") + + local singleBrandTriggerRate = build.calcsTab.mainOutput.SkillTriggerRate + build.configTab.input.customMods = "You can have an additional Brand Attached to an Enemy" + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.are.near(singleBrandTriggerRate * 2, build.calcsTab.mainOutput.SkillTriggerRate, 10 ^ -9) + assert.matches("2 attached Brands", build.calcsTab.mainEnv.player.mainSkill.infoMessage) + end) + it("Trigger Shockwave", function() build.itemsTab:CreateDisplayItemFromRaw([[Elemental 1H Mace Behemoth Mace diff --git a/src/Data/Skills/act_int.lua b/src/Data/Skills/act_int.lua index 8a00e075a0..d2452d63d9 100644 --- a/src/Data/Skills/act_int.lua +++ b/src/Data/Skills/act_int.lua @@ -21105,27 +21105,37 @@ skills["WintertideBrand"] = { statDescriptionScope = "brand_skill_stat_descriptions", castTime = 0.7, preDamageFunc = function(activeSkill, output) + activeSkill.skillData.countsAttachedBrandsInDamage = activeSkill.skillPart == 1 activeSkill.skillData.hitTimeOverride = activeSkill.skillData.repeatFrequency / (1 + activeSkill.skillModList:Sum("INC", activeSkill.skillCfg, "Speed", "BrandActivationFrequency") / 100) / activeSkill.skillModList:More(activeSkill.skillCfg, "BrandActivationFrequency") - if activeSkill.skillPart == 2 then + if activeSkill.skillPart == 1 then local skillMaxStages = activeSkill.skillModList:Sum("BASE", activeSkill.skillCfg, "Multiplier:WintertideBrandMaxStages") local debuffDurationMult = 1 / math.max(data.misc.BuffExpirationSlowCap, calcLib.mod(activeSkill.actor.enemy.modDB, activeSkill.skillCfg, "BuffExpireFaster")) local duration = calcSkillDuration(activeSkill.skillModList, activeSkill.skillCfg, activeSkill.skillData, {}) * debuffDurationMult - local maxStages = math.min(duration / activeSkill.skillData.hitTimeOverride + 1, skillMaxStages) - local timeToReachMaxStages = (maxStages - 1) * activeSkill.skillData.hitTimeOverride - local timeAtMaxStages = duration - timeToReachMaxStages + local maxStages = math.min(math.floor(duration / activeSkill.skillData.hitTimeOverride), skillMaxStages) + local timeToReachMaxStages = maxStages * activeSkill.skillData.hitTimeOverride + local timeAtMaxStages = math.max(duration - timeToReachMaxStages, 0) local damagePerStage = activeSkill.skillModList:Sum("BASE", activeSkill.skillCfg, "Multiplier:WintertideBrandDamagePerStage") - -- Get the average damage before reaching max stages and then damage at max stages - local dpsMultiplier = ((2 + damagePerStage + maxStages * damagePerStage) / 2 * timeToReachMaxStages + timeAtMaxStages * (1 + maxStages * damagePerStage)) / duration + -- Each activation adds one stage; average the completed stages over the attached duration + local averageStages = (maxStages * (maxStages - 1) / 2 * activeSkill.skillData.hitTimeOverride + maxStages * timeAtMaxStages) / duration + local averageDamageMultiplier = averageStages * damagePerStage + local endDurationBase = (activeSkill.skillData.durationTertiary or 0) + activeSkill.skillModList:Sum("BASE", activeSkill.skillCfg, "Duration", "TertiaryDuration") + local endDurationMod = math.max(calcLib.mod(activeSkill.skillModList, activeSkill.skillCfg, "Duration", "TertiaryDuration"), 0) + local endDuration = math.ceil(endDurationBase * endDurationMod * debuffDurationMult * data.misc.ServerTickRate) / data.misc.ServerTickRate + local endUptime = math.min(endDuration / duration, 1) + local attachedBrandCount = activeSkill.skillData.attachedBrandCount + -- Attached Wintertide debuffs stack, but only the strongest maximum-stage Wintertide's End debuff deals damage + local endDamageMultiplier = maxStages * damagePerStage + local dpsMultiplier = attachedBrandCount * (100 + averageDamageMultiplier) + endUptime * (100 + endDamageMultiplier) - 100 activeSkill.skillModList:NewMod("Damage", "MORE", dpsMultiplier, "Wintertide Brand Average Multiplier") end end, parts = { { - name = "Manual Stages", - stages = true + name = "Average Damage", }, { - name = "Average Damage", + name = "Manual Stages", + stages = true } }, statMap = { @@ -21133,7 +21143,7 @@ skills["WintertideBrand"] = { }, ["immolation_brand_burn_damage_+%_final_per_stage"] = { -- Only apply to Manual Stages part - mod("Damage", "MORE", nil, 0, 0, { type = "Multiplier", var = "WintertideBrandStage", limitVar = "WintertideBrandMaxStages" }, { type = "SkillPart", skillPart = 1 }), + mod("Damage", "MORE", nil, 0, 0, { type = "Multiplier", var = "WintertideBrandStage", limitVar = "WintertideBrandMaxStages" }, { type = "SkillPart", skillPart = 2 }), mod("Multiplier:WintertideBrandDamagePerStage", "BASE", nil), }, ["winter_brand_max_number_of_stages"] = { diff --git a/src/Export/Skills/act_int.txt b/src/Export/Skills/act_int.txt index 1d47c4af91..5f13dbd842 100644 --- a/src/Export/Skills/act_int.txt +++ b/src/Export/Skills/act_int.txt @@ -4594,27 +4594,37 @@ local skills, mod, flag, skill = ... #skill WintertideBrand #flags spell area duration brand preDamageFunc = function(activeSkill, output) + activeSkill.skillData.countsAttachedBrandsInDamage = activeSkill.skillPart == 1 activeSkill.skillData.hitTimeOverride = activeSkill.skillData.repeatFrequency / (1 + activeSkill.skillModList:Sum("INC", activeSkill.skillCfg, "Speed", "BrandActivationFrequency") / 100) / activeSkill.skillModList:More(activeSkill.skillCfg, "BrandActivationFrequency") - if activeSkill.skillPart == 2 then + if activeSkill.skillPart == 1 then local skillMaxStages = activeSkill.skillModList:Sum("BASE", activeSkill.skillCfg, "Multiplier:WintertideBrandMaxStages") local debuffDurationMult = 1 / math.max(data.misc.BuffExpirationSlowCap, calcLib.mod(activeSkill.actor.enemy.modDB, activeSkill.skillCfg, "BuffExpireFaster")) local duration = calcSkillDuration(activeSkill.skillModList, activeSkill.skillCfg, activeSkill.skillData, {}) * debuffDurationMult - local maxStages = math.min(duration / activeSkill.skillData.hitTimeOverride + 1, skillMaxStages) - local timeToReachMaxStages = (maxStages - 1) * activeSkill.skillData.hitTimeOverride - local timeAtMaxStages = duration - timeToReachMaxStages + local maxStages = math.min(math.floor(duration / activeSkill.skillData.hitTimeOverride), skillMaxStages) + local timeToReachMaxStages = maxStages * activeSkill.skillData.hitTimeOverride + local timeAtMaxStages = math.max(duration - timeToReachMaxStages, 0) local damagePerStage = activeSkill.skillModList:Sum("BASE", activeSkill.skillCfg, "Multiplier:WintertideBrandDamagePerStage") - -- Get the average damage before reaching max stages and then damage at max stages - local dpsMultiplier = ((2 + damagePerStage + maxStages * damagePerStage) / 2 * timeToReachMaxStages + timeAtMaxStages * (1 + maxStages * damagePerStage)) / duration + -- Each activation adds one stage; average the completed stages over the attached duration + local averageStages = (maxStages * (maxStages - 1) / 2 * activeSkill.skillData.hitTimeOverride + maxStages * timeAtMaxStages) / duration + local averageDamageMultiplier = averageStages * damagePerStage + local endDurationBase = (activeSkill.skillData.durationTertiary or 0) + activeSkill.skillModList:Sum("BASE", activeSkill.skillCfg, "Duration", "TertiaryDuration") + local endDurationMod = math.max(calcLib.mod(activeSkill.skillModList, activeSkill.skillCfg, "Duration", "TertiaryDuration"), 0) + local endDuration = math.ceil(endDurationBase * endDurationMod * debuffDurationMult * data.misc.ServerTickRate) / data.misc.ServerTickRate + local endUptime = math.min(endDuration / duration, 1) + local attachedBrandCount = activeSkill.skillData.attachedBrandCount + -- Attached Wintertide debuffs stack, but only the strongest maximum-stage Wintertide's End debuff deals damage + local endDamageMultiplier = maxStages * damagePerStage + local dpsMultiplier = attachedBrandCount * (100 + averageDamageMultiplier) + endUptime * (100 + endDamageMultiplier) - 100 activeSkill.skillModList:NewMod("Damage", "MORE", dpsMultiplier, "Wintertide Brand Average Multiplier") end end, parts = { { - name = "Manual Stages", - stages = true + name = "Average Damage", }, { - name = "Average Damage", + name = "Manual Stages", + stages = true } }, statMap = { @@ -4622,7 +4632,7 @@ local skills, mod, flag, skill = ... }, ["immolation_brand_burn_damage_+%_final_per_stage"] = { -- Only apply to Manual Stages part - mod("Damage", "MORE", nil, 0, 0, { type = "Multiplier", var = "WintertideBrandStage", limitVar = "WintertideBrandMaxStages" }, { type = "SkillPart", skillPart = 1 }), + mod("Damage", "MORE", nil, 0, 0, { type = "Multiplier", var = "WintertideBrandStage", limitVar = "WintertideBrandMaxStages" }, { type = "SkillPart", skillPart = 2 }), mod("Multiplier:WintertideBrandDamagePerStage", "BASE", nil), }, ["winter_brand_max_number_of_stages"] = { diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 956ac30db3..28d249b583 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -1418,6 +1418,7 @@ function calcs.offence(env, actor, activeSkill) output.BrandAttachmentRange = data.misc.BrandAttachmentRangeBase * calcLib.mod(skillModList, skillCfg, "BrandAttachmentRange") output.BrandAttachmentRangeMetre = output.BrandAttachmentRange / 10 output.ActiveBrandLimit = skillModList:Sum("BASE", skillCfg, "ActiveBrandLimit") + output.AttachedBrandCount = skillData.attachedBrandCount if breakdown then breakdown.BrandAttachmentRange = { radius = output.BrandAttachmentRange } end @@ -1839,6 +1840,11 @@ function calcs.offence(env, actor, activeSkill) runSkillFunc("preDamageFunc") + if activeSkill.skillTypes[SkillType.Brand] then + local damageLabel = skillData.countsAttachedBrandsInDamage and "Average Damage" or "DPS" + activeSkill.infoMessage = s_format("%s for %d attached Brand%s", damageLabel, output.AttachedBrandCount, output.AttachedBrandCount == 1 and "" or "s") + end + -- Handle corpse and enemy explosions local monsterLife = skillData.corpseLife or (env.enemyLevel and data.monsterLifeTable[env.enemyLevel] or 100) if skillData.explodeCorpse and (skillData.corpseLife or env.enemyLevel) then @@ -2407,6 +2413,10 @@ function calcs.offence(env, actor, activeSkill) end -- Other Misc DPS multipliers (like custom source) skillData.dpsMultiplier = ( skillData.dpsMultiplier or 1 ) * ( 1 + skillModList:Sum("INC", skillCfg, "DPS") / 100 ) * skillModList:More(skillCfg, "DPS") + if activeSkill.skillTypes[SkillType.Brand] and not skillData.countsAttachedBrandsInDamage then + skillData.dpsMultiplier = skillData.dpsMultiplier * output.AttachedBrandCount + output.SkillDPSMultiplier = (output.SkillDPSMultiplier or 1) * output.AttachedBrandCount + end if env.configInput.repeatMode == "FINAL" or skillModList:Flag(nil, "OnlyFinalRepeat") then skillData.dpsMultiplier = skillData.dpsMultiplier / (output.Repeats or 1) end @@ -5683,8 +5693,9 @@ function calcs.offence(env, actor, activeSkill) if skillModList:Flag(nil, "DotCanStackAsTotems") and skillFlags.totem then skillFlags.DotCanStack = true end - output.TotalDot = output.TotalDotInstance - output.TotalDotCalcSection = output.TotalDotInstance + local attachedBrandCount = activeSkill.skillTypes[SkillType.Brand] and not skillData.countsAttachedBrandsInDamage and output.AttachedBrandCount or 1 + output.TotalDot = attachedBrandCount > 1 and m_min(output.TotalDotInstance * attachedBrandCount, data.misc.DotDpsCap) or output.TotalDotInstance + output.TotalDotCalcSection = output.TotalDot end --Calculates and displays cost per second for skills that don't already have one (link skills) diff --git a/src/Modules/CalcPerform.lua b/src/Modules/CalcPerform.lua index 6c4f13dec2..73025efe92 100644 --- a/src/Modules/CalcPerform.lua +++ b/src/Modules/CalcPerform.lua @@ -1320,14 +1320,15 @@ function calcs.perform(env, skipEHP) for _, activeSkill in ipairs(env.player.activeSkillList) do if activeSkill.skillTypes[SkillType.Brand] then - local attachLimit = activeSkill.skillModList:Sum("BASE", activeSkill.skillCfg, "BrandsAttachedLimit") - local attached = modDB:Sum("BASE", nil, "Multiplier:ConfigBrandsAttachedToEnemy") + local attachLimit = m_min(activeSkill.skillModList:Sum("BASE", activeSkill.skillCfg, "BrandsAttachedLimit"), activeSkill.skillModList:Sum("BASE", activeSkill.skillCfg, "ActiveBrandLimit")) + local configured = modDB:Sum("BASE", nil, "Multiplier:ConfigBrandsAttachedToEnemy") + local attached = configured > 0 and m_min(configured, attachLimit) or attachLimit + activeSkill.skillData.attachedBrandCount = attached local activeBrands = modDB:Sum("BASE", nil, "Multiplier:ConfigActiveBrands") - local actual = m_min(attachLimit, attached) -- Cap the number of active brands by the limit, which is 3 by default modDB.multipliers["ActiveBrand"] = m_min(activeBrands, modDB:Sum("BASE", nil, "ActiveBrandLimit")) - modDB.multipliers["BrandsAttachedToEnemy"] = m_max(actual, modDB.multipliers["BrandsAttachedToEnemy"] or 0) - enemyDB.multipliers["BrandsAttached"] = m_max(actual, enemyDB.multipliers["BrandsAttached"] or 0) + modDB.multipliers["BrandsAttachedToEnemy"] = m_max(attached, modDB.multipliers["BrandsAttachedToEnemy"] or 0) + enemyDB.multipliers["BrandsAttached"] = m_max(attached, enemyDB.multipliers["BrandsAttached"] or 0) end if activeSkill.skillFlags.totem then local limit = env.player.mainSkill.skillModList:Sum("BASE", env.player.mainSkill.skillCfg, "ActiveTotemLimit", "ActiveBallistaLimit" ) diff --git a/src/Modules/CalcTriggers.lua b/src/Modules/CalcTriggers.lua index f3d1e0693e..13f699938e 100644 --- a/src/Modules/CalcTriggers.lua +++ b/src/Modules/CalcTriggers.lua @@ -422,6 +422,7 @@ local function defaultTriggerHandler(env, config) s_format("%.2f ^8(base activation cooldown of %s)", actor.mainSkill.triggeredBy.mainSkill.skillData.repeatFrequency, config.triggerName), s_format("* %.2f ^8(more activation frequency)", actor.mainSkill.triggeredBy.activationFreqMore), s_format("* %.2f ^8(increased activation frequency)", actor.mainSkill.triggeredBy.activationFreqInc), + s_format("* %d ^8(attached Brands)", actor.mainSkill.triggeredBy.attachedBrandCount), s_format("= %.2f ^8(activation rate of %s)", trigRate, actor.mainSkill.triggeredBy.mainSkill.activeEffect.grantedEffect.name) } elseif breakdown then @@ -878,6 +879,10 @@ local function defaultTriggerHandler(env, config) else actor.mainSkill.infoMessage = actor.mainSkill.triggeredBy and actor.mainSkill.triggeredBy.grantedEffect.name or config.triggerName .. " Trigger" end + if actor.mainSkill.skillData.triggeredByBrand then + local attachedBrandCount = actor.mainSkill.triggeredBy.attachedBrandCount + actor.mainSkill.infoMessage = actor.mainSkill.infoMessage .. ":" .. s_format("%d attached Brand%s", attachedBrandCount, attachedBrandCount == 1 and "" or "s") + end actor.mainSkill.infoTrigger = config.triggerName end @@ -1322,8 +1327,9 @@ local configTable = { local activationFreqMore = env.player.mainSkill.triggeredBy.mainSkill.skillModList:More(env.player.mainSkill.triggeredBy.mainSkill.skillCfg, "BrandActivationFrequency") env.player.mainSkill.triggeredBy.activationFreqInc = activationFreqInc env.player.mainSkill.triggeredBy.activationFreqMore = activationFreqMore + env.player.mainSkill.triggeredBy.attachedBrandCount = env.player.mainSkill.triggeredBy.mainSkill.skillData.attachedBrandCount env.player.mainSkill.triggeredBy.ignoresTickRate = true - return {trigRate = env.player.mainSkill.triggeredBy.mainSkill.skillData.repeatFrequency * activationFreqInc * activationFreqMore, + return {trigRate = env.player.mainSkill.triggeredBy.mainSkill.skillData.repeatFrequency * activationFreqInc * activationFreqMore * env.player.mainSkill.triggeredBy.attachedBrandCount, source = env.player.mainSkill.triggeredBy.mainSkill, triggeredSkillCond = function(env, skill) return skill.skillData.triggeredByBrand and slotMatch(env, skill) end} end diff --git a/src/Modules/ConfigOptions.lua b/src/Modules/ConfigOptions.lua index c3045ef923..bf65c93d0f 100644 --- a/src/Modules/ConfigOptions.lua +++ b/src/Modules/ConfigOptions.lua @@ -315,7 +315,7 @@ return { { var = "ActiveBrands", type = "count", label = "# of active Brands:", ifSkill = { "Armageddon Brand", "Storm Brand", "Arcanist Brand", "Penance Brand", "Wintertide Brand" }, includeTransfigured = true , apply = function(val, modList, enemyModList) modList:NewMod("Multiplier:ConfigActiveBrands", "BASE", val, "Config") end }, - { var = "BrandsAttachedToEnemy", type = "count", label = "# of Brands attached to the enemy:", ifEnemyMult = "BrandsAttached", apply = function(val, modList, enemyModList) + { var = "BrandsAttachedToEnemy", type = "count", label = "# of Brands attached to enemy (if not maximum):", ifSkillFlag = "brand", apply = function(val, modList, enemyModList) modList:NewMod("Multiplier:ConfigBrandsAttachedToEnemy", "BASE", val, "Config") end }, { var = "targetBrandedEnemy", type = "check", label = "Skill is targeting the Branded enemy", ifCond = "TargetingBrandedEnemy", defaultState = true, apply = function(val, modList, enemyModList)