From 1adfeb3aa11990d1deb2a8291b512dfc134e5392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=A0i=C5=A1ka?= Date: Thu, 17 Sep 2026 20:11:28 +0200 Subject: [PATCH 1/2] Add aftershock chance for Slam skills and model Earthquake's Aftershock Parse the Earthbreaker (Titan) ascendancy notable and the related passive nodes (Split the Earth, Mace Aftershock Chance, Behemoth, Rhythm of Fire, Echoes of Ferocity, Shapeshifted Aftershock Chance) into the existing AftershockChance stat that the Aftershock support gems and gem quality already provide. For Slam skills the summed chance scales the per-skill DPS multiplier by 1 + chance / 100. The chance is not capped, as each full 100% guarantees an aftershock and the remainder is a chance for one more. Average hit is unchanged; DPS and ailment stacking scale accordingly. Show an "Aftershock Chance" row in Skill type-specific Stats and list the aftershock factor in the Total DPS breakdown. Earthquake: Jagged Ground cannot be created on top of an existing patch, so the Aftershock cannot hit a target more often than once per patch duration. The Aftershock stat set had no duration, so its DPS scaled linearly with attack speed and ignored skill effect duration. Give the set the 4s base duration and a hitRateLimitedByDuration flag: the hit time is the larger of the attack time and the skill duration (recomputed from the combined attack time when dual wielding), the set displays DPS instead of average damage, and the Hit Rate breakdown shows the cap. The additional aftershock chance only applies to the Aftershock set, as the initial Impact is the slam itself and does not repeat. Regenerate ModCache and add tests. Co-Authored-By: Claude Fable 5.1 --- spec/System/TestAftershock_spec.lua | 87 +++++++++++++++++++++ spec/System/TestEarthquake_spec.lua | 112 ++++++++++++++++++++++++++++ src/Data/ModCache.lua | 25 +++---- src/Data/Skills/act_str.lua | 6 ++ src/Export/Skills/act_str.txt | 5 +- src/Modules/CalcOffence.lua | 40 +++++++++- src/Modules/CalcSections.lua | 1 + src/Modules/ModParser.lua | 5 ++ 8 files changed, 263 insertions(+), 18 deletions(-) create mode 100644 spec/System/TestAftershock_spec.lua create mode 100644 spec/System/TestEarthquake_spec.lua diff --git a/spec/System/TestAftershock_spec.lua b/spec/System/TestAftershock_spec.lua new file mode 100644 index 0000000000..be02e3671b --- /dev/null +++ b/spec/System/TestAftershock_spec.lua @@ -0,0 +1,87 @@ +describe("TestAftershock", function() + before_each(function() + newBuild() + end) + + teardown(function() + -- newBuild() takes care of resetting everything in setup() + end) + + local function setupSkill(weaponBase, gemLine, customMods) + if weaponBase then + build.itemsTab:CreateDisplayItemFromRaw("New Item\n" .. weaponBase) + build.itemsTab:AddDisplayItem() + end + build.skillsTab:PasteSocketGroup(gemLine) + build.mainSocketGroup = 1 + build.configTab.input.customMods = customMods or "" + build.configTab:BuildModList() + runCallback("OnFrame") + return build.calcsTab.mainOutput + end + + local function assertClose(expected, actual) + assert.is_true(math.abs(expected - actual) < 0.001, string.format("expected %s, got %s", tostring(expected), tostring(actual))) + end + + it("Earthbreaker gives slam skills a chance to cause an additional aftershock, scaling DPS but not average hit", function() + local base = setupSkill("Wooden Club", "Leap Slam 20/0 1") + local baseDPS, baseAvg = base.TotalDPS, base.AverageDamage + assert.is_true(baseDPS > 0) + assert.are.equals(0, base.AftershockChance) + + newBuild() + local out = setupSkill("Wooden Club", "Leap Slam 20/0 1", "25% chance for Slam Skills you use yourself to cause an additional Aftershock") + assert.are.equals(25, out.AftershockChance) + assertClose(baseDPS * 1.25, out.TotalDPS) + assertClose(baseAvg, out.AverageDamage) + end) + + it("does not give non-slam skills an aftershock chance", function() + local out = setupSkill("Wooden Club", "Perfect Strike 20/0 1", "25% chance for Slam Skills you use yourself to cause an additional Aftershock") + assert.is_true(out.TotalDPS > 0) + assert.is_nil(out.AftershockChance) + end) + + it("stacks aftershock chance from passives and support gems without a cap", function() + local base = setupSkill("Wooden Club", "Leap Slam 20/0 1") + local baseDPS = base.TotalDPS + + newBuild() + local out = setupSkill("Wooden Club", "Leap Slam 20/0 1", [[ + 25% chance for Slam Skills you use yourself to cause an additional Aftershock + 10% chance for Mace Slam Skills you use yourself to cause an additional Aftershock + 5% chance for Slam Skills to cause an additional Aftershock + ]]) + assert.are.equals(40, out.AftershockChance) + + newBuild() + out = setupSkill("Wooden Club", "Leap Slam 20/0 1\nAftershock II 20/0 1", "80% chance for Slam Skills you use yourself to cause an additional Aftershock") + assert.are.equals(115, out.AftershockChance) + assertClose(baseDPS * 2.15, out.TotalDPS) + end) + + it("only applies mace slam aftershock chance when attacking with a mace", function() + local out = setupSkill("Hardwood Spear", "Thunderous Leap 20/0 1", "10% chance for Mace Slam Skills you use yourself to cause an additional Aftershock") + assert.is_true(out.TotalDPS > 0) + assert.are.equals(0, out.AftershockChance) + + newBuild() + out = setupSkill("Hardwood Spear", "Thunderous Leap 20/0 1", "25% chance for Slam Skills you use yourself to cause an additional Aftershock") + assert.are.equals(25, out.AftershockChance) + + newBuild() + out = setupSkill("Wooden Club", "Leap Slam 20/0 1", "10% chance for Mace Slam Skills you use yourself to cause an additional Aftershock") + assert.are.equals(10, out.AftershockChance) + end) + + it("only applies shapeshift slam aftershock chance to shapeshift slams", function() + local out = setupSkill("Changeling Talisman", "Rampage 20/0 1", "15% chance for Shapeshift Slam Skills you use yourself to cause an additional Aftershock") + assert.is_true(out.TotalDPS > 0) + assert.are.equals(15, out.AftershockChance) + + newBuild() + out = setupSkill("Wooden Club", "Leap Slam 20/0 1", "15% chance for Shapeshift Slam Skills you use yourself to cause an additional Aftershock") + assert.are.equals(0, out.AftershockChance) + end) +end) diff --git a/spec/System/TestEarthquake_spec.lua b/spec/System/TestEarthquake_spec.lua new file mode 100644 index 0000000000..c8c2871b38 --- /dev/null +++ b/spec/System/TestEarthquake_spec.lua @@ -0,0 +1,112 @@ +describe("TestEarthquake", function() + before_each(function() + newBuild() + end) + + teardown(function() + -- newBuild() takes care of resetting everything in setup() + end) + + -- statSetIndex 1 = Impact, 2 = Aftershock + local function setupEarthquake(statSetIndex, customMods, weapons) + for _, weapon in ipairs(weapons or { "Wooden Club" }) do + build.itemsTab:CreateDisplayItemFromRaw("New Item\n" .. weapon) + build.itemsTab:AddDisplayItem() + end + build.skillsTab:PasteSocketGroup("Earthquake 20/0 1") + local gemInstance = build.skillsTab.socketGroupList[1].gemList[1] + gemInstance.statSet = { EarthquakePlayer = statSetIndex } + gemInstance.statSetCalcs = { EarthquakePlayer = statSetIndex } + build.mainSocketGroup = 1 + build.configTab.input.customMods = customMods or "" + build.configTab:BuildModList() + runCallback("OnFrame") + return build.calcsTab.mainOutput + end + + local function assertClose(expected, actual) + assert.is_true(math.abs(expected - actual) < 0.001, string.format("expected %s, got %s", tostring(expected), tostring(actual))) + end + + it("caps the Aftershock hit rate at once per Jagged Ground duration", function() + local out = setupEarthquake(2) + assert.is_true(out.Duration > 0) + assert.is_true(out.Speed > 1 / out.Duration, "test assumes attack time is shorter than the patch duration") + assertClose(1 / out.Duration, out.HitSpeed) + assertClose(out.AverageDamage * out.HitSpeed * out.DpsMultiplier, out.TotalDPS) + assert.is_falsy(build.calcsTab.mainEnv.player.mainSkill.skillData.showAverage) + end) + + it("does not scale Aftershock DPS with attack speed while the patch duration is the limit", function() + local base = setupEarthquake(2) + + newBuild() + local out = setupEarthquake(2, "200% increased Attack Speed") + assert.is_true(out.Speed > base.Speed) + assertClose(base.HitSpeed, out.HitSpeed) + assertClose(base.TotalDPS, out.TotalDPS) + end) + + it("scales Aftershock DPS with reduced skill effect duration", function() + local base = setupEarthquake(2) + + newBuild() + local out = setupEarthquake(2, "50% reduced Skill Effect Duration") + assertClose(base.Duration / 2, out.Duration) + assertClose(1 / out.Duration, out.HitSpeed) + assertClose(base.TotalDPS * 2, out.TotalDPS) + end) + + it("uses the attack rate for Aftershock once the patch duration is shorter than the attack time", function() + local out = setupEarthquake(2, "80% reduced Skill Effect Duration") + assert.is_true(out.Speed < 1 / out.Duration, "test assumes attack time is longer than the patch duration") + assertClose(out.Speed, out.HitSpeed) + assertClose(out.AverageDamage * out.Speed * out.DpsMultiplier, out.TotalDPS) + + newBuild() + out = setupEarthquake(2, "100% reduced Skill Effect Duration\n200% increased Attack Speed") + assert.are.equals(0, out.Duration) + assertClose(out.AverageDamage * out.Speed * out.DpsMultiplier, out.TotalDPS) + end) + + it("caps the Aftershock hit rate using the combined attack time when dual wielding", function() + local weapons = { "Marauding Mace\n20% increased Attack Speed", "Marauding Mace" } + local out = setupEarthquake(2, nil, weapons) + assert.is_true(out.MainHand.Speed ~= out.OffHand.Speed, "test assumes the two weapons attack at different speeds") + assertClose(1 / out.Duration, out.HitSpeed) + assertClose(out.AverageDamage * out.HitSpeed * out.DpsMultiplier, out.TotalDPS) + + newBuild() + out = setupEarthquake(2, "85% reduced Skill Effect Duration", weapons) + assert.is_true(out.Speed < 1 / out.Duration, "test assumes the combined attack time is longer than the patch duration") + assert.is_true(out.Speed ~= out.MainHand.Speed, "test assumes the combined attack rate differs from the main hand rate") + assertClose(out.Speed, out.HitSpeed) + assertClose(out.AverageDamage * out.Speed * out.DpsMultiplier, out.TotalDPS) + end) + + it("applies additional aftershock chance to the Aftershock but not to the Impact", function() + local aftershockMod = "25% chance for Slam Skills you use yourself to cause an additional Aftershock" + local base = setupEarthquake(1) + newBuild() + local out = setupEarthquake(1, aftershockMod) + assert.is_falsy(out.AftershockChance and out.AftershockChance > 0) + assertClose(base.TotalDPS, out.TotalDPS) + + newBuild() + base = setupEarthquake(2) + newBuild() + out = setupEarthquake(2, aftershockMod) + assert.are.equals(25, out.AftershockChance) + assertClose(base.TotalDPS * 1.25, out.TotalDPS) + end) + + it("does not cap the Impact hit rate", function() + local base = setupEarthquake(1) + assert.is_nil(base.HitSpeed) + + newBuild() + local out = setupEarthquake(1, "200% increased Attack Speed") + assert.is_nil(out.HitSpeed) + assertClose(base.TotalDPS * out.Speed / base.Speed, out.TotalDPS) + end) +end) diff --git a/src/Data/ModCache.lua b/src/Data/ModCache.lua index 701abf6fd2..267d97b7d3 100644 --- a/src/Data/ModCache.lua +++ b/src/Data/ModCache.lua @@ -1456,9 +1456,8 @@ c["10% chance for Attack Hits to apply ten Incision"]={{[1]={flags=0,keywordFlag c["10% chance for Enemies you Kill to Explode, dealing 100%"]={{}," for Enemies you Kill to Explode, dealing 100% "} c["10% chance for Enemies you Kill to Explode, dealing 100% of their maximum Life as Physical Damage"]={{[1]={flags=0,keywordFlags=0,name="ExplodeMod",type="LIST",value={amount=100,keyOfScaledMod="value",type="Physical",value=10}},[2]={flags=0,keywordFlags=0,name="CanExplode",type="FLAG",value=true}},nil} c["10% chance for Flasks you use to not consume Charges"]={{[1]={flags=0,keywordFlags=0,name="FlaskChanceNotConsumeCharges",type="BASE",value=10}},nil} -c["10% chance for Mace Slam Skills you use yourself to cause an additional Aftershock"]={{}," for Mace Slam Skills you use yourself to cause an additional Aftershock "} -c["10% chance for Mace Slam Skills you use yourself to cause an additional Aftershock Strike Skills you use yourself with Maces have 10% chance to deal Splash Damage"]={{[1]={flags=1048580,keywordFlags=0,name="Damage",type="BASE",value=10}}," for Mace Slam Skills you use yourself to cause an additional Aftershock Strike Skills you use yourself have 10% chance to deal Splash "} -c["10% chance for Shapeshift Slam Skills you use yourself to cause an additional Aftershock"]={{}," for Shapeshift Slam Skills you use yourself to cause an additional Aftershock "} +c["10% chance for Mace Slam Skills you use yourself to cause an additional Aftershock"]={{[1]={[1]={skillType=93,type="SkillType"},flags=1048576,keywordFlags=0,name="AftershockChance",type="BASE",value=10}},nil} +c["10% chance for Shapeshift Slam Skills you use yourself to cause an additional Aftershock"]={{[1]={[1]={skillType=93,type="SkillType"},[2]={skillType=157,type="SkillType"},flags=0,keywordFlags=0,name="AftershockChance",type="BASE",value=10}},nil} c["10% chance to Aggravate Bleeding on targets you Hit with Attacks"]={{}," to Aggravate Bleeding on targets you Hit "} c["10% chance to Aggravate Bleeding on targets you Hit with Attacks 8% increased Attack Speed while a Rare or Unique Enemy is in your Presence"]={{[1]={[1]={actor="enemy",type="ActorCondition",varList={[1]="NearbyRareOrUniqueEnemy",[2]="RareOrUnique"}},flags=1,keywordFlags=65536,name="Speed",type="BASE",value=10}}," to Aggravate Bleeding on targets you Hit 8% increased "} c["10% chance to Avoid Elemental Ailments"]={{[1]={flags=0,keywordFlags=0,name="AvoidElementalAilments",type="BASE",value=10}},nil} @@ -2074,7 +2073,7 @@ c["125% increased amount of Mana Leeched if you've dealt a Critical Hit Recently c["13 Mana gained when you Block"]={{[1]={flags=0,keywordFlags=0,name="ManaOnBlock",type="BASE",value=13}},nil} c["13 to 23 Physical Thorns damage"]={{[1]={flags=32,keywordFlags=0,name="PhysicalMin",type="BASE",value=13},[2]={flags=32,keywordFlags=0,name="PhysicalMax",type="BASE",value=23}},nil} c["13% chance for Flasks you use to not consume Charges"]={{[1]={flags=0,keywordFlags=0,name="FlaskChanceNotConsumeCharges",type="BASE",value=13}},nil} -c["13% chance for Mace Slam Skills you use yourself to cause an additional Aftershock"]={{}," for Mace Slam Skills you use yourself to cause an additional Aftershock "} +c["13% chance for Mace Slam Skills you use yourself to cause an additional Aftershock"]={{[1]={[1]={skillType=93,type="SkillType"},flags=1048576,keywordFlags=0,name="AftershockChance",type="BASE",value=13}},nil} c["13% chance for Spell Skills to fire 2 additional Projectiles"]={{[1]={flags=2,keywordFlags=0,name="TwoAdditionalProjectilesChance",type="BASE",value=13}},nil} c["13% chance to Gain Arcane Surge when you deal a Critical Hit"]={{[1]={[1]={type="Condition",var="CritRecently"},flags=0,keywordFlags=0,name="Condition:ArcaneSurge",type="FLAG",value=true}},nil} c["13% chance to Maim on Hit"]={{}," to Maim "} @@ -2194,7 +2193,7 @@ c["15 to 25 Physical Thorns damage"]={{[1]={flags=32,keywordFlags=0,name="Physic c["15% Surpassing Chance to gain a Puppet Master stack whenever you use a Command Skill"]={{},"% Surpassing Chance to gain a Puppet Master stack whenever you use a Command Skill "} c["15% additional Physical Damage Reduction"]={{[1]={flags=0,keywordFlags=0,name="PhysicalDamageReduction",type="BASE",value=15}},nil} c["15% chance for Remnants you create to grant their effects twice"]={{[1]={flags=0,keywordFlags=0,name="LocalEffect",type="BASE",value=15}}," for Remnants you create to grant their s twice "} -c["15% chance for Shapeshift Slam Skills you use yourself to cause an additional Aftershock"]={{}," for Shapeshift Slam Skills you use yourself to cause an additional Aftershock "} +c["15% chance for Shapeshift Slam Skills you use yourself to cause an additional Aftershock"]={{[1]={[1]={skillType=93,type="SkillType"},[2]={skillType=157,type="SkillType"},flags=0,keywordFlags=0,name="AftershockChance",type="BASE",value=15}},nil} c["15% chance that if you would gain Endurance Charges, you instead gain up to maximum Endurance Charges"]={{[1]={flags=0,keywordFlags=0,name="FlaskCharges",type="BASE",value=15}}," that if you would gain Endurance , you instead gain up to maximum Endurance Charges "} c["15% chance that if you would gain Frenzy Charges, you instead gain up to your maximum number of Frenzy Charges"]={{[1]={flags=0,keywordFlags=0,name="FlaskCharges",type="BASE",value=15}}," that if you would gain Frenzy , you instead gain up to your maximum number of Frenzy Charges "} c["15% chance that if you would gain Power Charges, you instead gain up to your maximum number of Power Charges"]={{[1]={flags=0,keywordFlags=0,name="FlaskCharges",type="BASE",value=15}}," that if you would gain Power , you instead gain up to your maximum number of Power Charges "} @@ -2745,7 +2744,7 @@ c["20% chance for Energy Shield Recharge to start when you Kill an Enemy"]={{[1] c["20% chance for Flasks you use to not consume Charges"]={{[1]={flags=0,keywordFlags=0,name="FlaskChanceNotConsumeCharges",type="BASE",value=20}},nil} c["20% chance for Lightning Damage with Hits to be Lucky"]={{[1]={flags=0,keywordFlags=0,name="LightningLuckyHitsChance",type="BASE",value=20}},nil} c["20% chance for Lightning Skills to Chain an additional time"]={{[1]={flags=0,keywordFlags=128,name="ChainChance",type="BASE",value=20}},nil} -c["20% chance for Mace Slam Skills you use yourself to cause an additional Aftershock"]={{}," for Mace Slam Skills you use yourself to cause an additional Aftershock "} +c["20% chance for Mace Slam Skills you use yourself to cause an additional Aftershock"]={{[1]={[1]={skillType=93,type="SkillType"},flags=1048576,keywordFlags=0,name="AftershockChance",type="BASE",value=20}},nil} c["20% chance to Aggravate Bleeding on targets you Critically Hit with Attacks"]={{}," to Aggravate Bleeding on targets you Critically Hit "} c["20% chance to Aggravate Bleeding on targets you Hit with Empowered Attacks"]={{}," to Aggravate Bleeding on targets you Hit with Empowered Attacks "} c["20% chance to Aggravate Bleeding on targets you Hit with Empowered Attacks Empowered Attacks deal 30% increased Damage"]={{[1]={flags=0,keywordFlags=0,name="Damage",type="BASE",value=20}}," to Aggravate Bleeding on targets you Hit with Empowered Attacks Empowered Attacks deal 30% increased "} @@ -3181,7 +3180,7 @@ c["25% chance for Attacks to Maim on Hit against Poisoned Enemies 25% increased c["25% chance for Lightning Damage with Hits to be Lucky"]={{[1]={flags=0,keywordFlags=0,name="LightningLuckyHitsChance",type="BASE",value=25}},nil} c["25% chance for Projectiles to Pierce Enemies within 3m distance of you"]={{[1]={flags=0,keywordFlags=0,name="ProjectileCount",type="BASE",value=25}}," for to Pierce Enemies within 3m distance of you "} c["25% chance for Skills to retain 40% of Glory on use"]={{}," for Skills to retain 40% of Glory on use "} -c["25% chance for Slam Skills you use yourself to cause an additional Aftershock"]={{}," for Slam Skills you use yourself to cause an additional Aftershock "} +c["25% chance for Slam Skills you use yourself to cause an additional Aftershock"]={{[1]={[1]={skillType=93,type="SkillType"},flags=0,keywordFlags=0,name="AftershockChance",type="BASE",value=25}},nil} c["25% chance for Trigger skills to refund half of Energy Spent"]={{}," for Trigger skills to refund half of Energy Spent "} c["25% chance on Consuming a Shock on an Enemy to reapply it"]={{}," on Consuming a Shock on an Enemy to reapply it "} c["25% chance on Shocking Enemies to created Shocked Ground"]={{[1]={[1]={actor="enemy",type="ActorCondition",var="OnShockedGround"},flags=0,keywordFlags=0,name="ShockBase",type="BASE",value=20}},nil} @@ -4336,8 +4335,8 @@ c["5 to 10 Physical Thorns damage"]={{[1]={flags=32,keywordFlags=0,name="Physica c["5 to 9 Physical Thorns damage"]={{[1]={flags=32,keywordFlags=0,name="PhysicalMin",type="BASE",value=5},[2]={flags=32,keywordFlags=0,name="PhysicalMax",type="BASE",value=9}},nil} c["5% Chance to build an additional Combo on Hit"]={{}," to build an additional Combo "} c["5% additional Physical Damage Reduction while you have at least 150 Devotion"]={{[1]={[1]={stat="Devotion",threshold=150,type="StatThreshold"},flags=0,keywordFlags=0,name="PhysicalDamageReduction",type="BASE",value=5}},nil} -c["5% chance for Slam Skills to cause an additional Aftershock"]={{}," for Slam Skills to cause an additional Aftershock "} -c["5% chance for Slam Skills you use yourself to cause an additional Aftershock"]={{}," for Slam Skills you use yourself to cause an additional Aftershock "} +c["5% chance for Slam Skills to cause an additional Aftershock"]={{[1]={[1]={skillType=93,type="SkillType"},flags=0,keywordFlags=0,name="AftershockChance",type="BASE",value=5}},nil} +c["5% chance for Slam Skills you use yourself to cause an additional Aftershock"]={{[1]={[1]={skillType=93,type="SkillType"},flags=0,keywordFlags=0,name="AftershockChance",type="BASE",value=5}},nil} c["5% chance to Blind Enemies on Hit"]={{[1]={flags=0,keywordFlags=0,name="BlindChance",type="BASE",value=5}},nil} c["5% chance to Blind Enemies on Hit with Attacks"]={{[1]={flags=0,keywordFlags=65536,name="BlindChance",type="BASE",value=5}},nil} c["5% chance to Daze on Hit"]={{[1]={flags=4,keywordFlags=0,name="DazeChance",type="BASE",value=5}},nil} @@ -5001,8 +5000,8 @@ c["75% increased chance to Shock"]={{[1]={flags=0,keywordFlags=0,name="EnemyShoc c["75% increased effect of Socketed Augment Items"]={{[1]={flags=0,keywordFlags=0,name="SocketedAugmentItemEffect",type="INC",value=75}},nil} c["75% more Stun Buildup with Lightning Damage"]={{[1]={[1]={type="Condition",var="LightningHasDamage"},flags=0,keywordFlags=0,name="EnemyHeavyStunBuildup",type="MORE",value=75}},nil} c["75% of Damage Converted to Fire Damage"]={{[1]={flags=0,keywordFlags=0,name="DamageConvertToFire",type="BASE",value=75}},nil} -end)();(function() c["75% of Volatility Physical Damage Taken as Cold Damage"]={{[1]={flags=0,keywordFlags=0,name="PhysicalDamageTakenAsCold",type="BASE",value=75}}," Volatility "} +end)();(function() c["75% reduced Amount Recovered"]={{[1]={flags=0,keywordFlags=0,name="FlaskRecovery",type="INC",value=-75}},nil} c["75% reduced Charges per use"]={{[1]={flags=0,keywordFlags=0,name="FlaskChargesUsed",type="INC",value=-75}},nil} c["75% reduced Ignite Duration on Enemies"]={{[1]={flags=0,keywordFlags=0,name="EnemyIgniteDuration",type="INC",value=-75}},nil} @@ -5013,8 +5012,8 @@ c["8 Life Regeneration per second"]={{[1]={flags=0,keywordFlags=0,name="LifeRege c["8 to 14 Fire Damage per Endurance Charge"]={{[1]={[1]={type="Multiplier",var="EnduranceCharge"},flags=0,keywordFlags=0,name="FireMin",type="BASE",value=8},[2]={[1]={type="Multiplier",var="EnduranceCharge"},flags=0,keywordFlags=0,name="FireMax",type="BASE",value=14}},nil} c["8% Global chance to Blind Enemies on Hit"]={{[1]={[1]={type="Global"},flags=0,keywordFlags=0,name="BlindChance",type="BASE",value=8}},"% chance "} c["8% additional Physical Damage Reduction"]={{[1]={flags=0,keywordFlags=0,name="PhysicalDamageReduction",type="BASE",value=8}},nil} -c["8% chance for Mace Slam Skills you use yourself to cause an additional Aftershock"]={{}," for Mace Slam Skills you use yourself to cause an additional Aftershock "} -c["8% chance for Slam Skills you use yourself to cause an additional Aftershock"]={{}," for Slam Skills you use yourself to cause an additional Aftershock "} +c["8% chance for Mace Slam Skills you use yourself to cause an additional Aftershock"]={{[1]={[1]={skillType=93,type="SkillType"},flags=1048576,keywordFlags=0,name="AftershockChance",type="BASE",value=8}},nil} +c["8% chance for Slam Skills you use yourself to cause an additional Aftershock"]={{[1]={[1]={skillType=93,type="SkillType"},flags=0,keywordFlags=0,name="AftershockChance",type="BASE",value=8}},nil} c["8% chance for Spell Skills to fire 2 additional Projectiles"]={{[1]={flags=2,keywordFlags=0,name="TwoAdditionalProjectilesChance",type="BASE",value=8}},nil} c["8% chance for Spell Skills to fire 8 additional Projectiles in a circle"]={{[1]={flags=2,keywordFlags=0,name="ProjectileCount",type="BASE",value=8}}," to fire 8 additional in a circle "} c["8% chance to Aggravate Bleeding on targets you Hit with Attacks"]={{}," to Aggravate Bleeding on targets you Hit "} @@ -10003,8 +10002,8 @@ c["for 4 seconds, every 0.25 seconds while raised"]={nil,"for 4 seconds, every 0 c["gain 6 Cold Surges or 6 Fire Surges"]={{}," Cold Surges or 6 Fire Surges "} c["the enemy's Power for 20 seconds, up to a total of 500"]={nil,"the enemy's Power for 20 seconds, up to a total of 500 "} c["their Explicit Modifiers are transformed into more powerful related Modifiers"]={nil,"their Explicit Modifiers are transformed into more powerful related Modifiers "} -end)();(function() c["their Explicit Modifiers are transformed into more powerful related Modifiers Ignore Attribute Requirements to equip Gloves"]={nil,"their Explicit Modifiers are transformed into more powerful related Modifiers Ignore Attribute Requirements to equip Gloves "} +end)();(function() c["until you take no Damage to Life for 3 seconds"]={nil,"until you take no Damage to Life for 3 seconds "} c["until you take no Damage to Life for 5 seconds"]={nil,"until you take no Damage to Life for 5 seconds "} c["until you take no Damage to Life for 5 seconds Life that would be lost by taking Damage is instead Reserved"]={nil,"until you take no Damage to Life for 5 seconds Life that would be lost by taking Damage is instead Reserved "} diff --git a/src/Data/Skills/act_str.lua b/src/Data/Skills/act_str.lua index 6cf0aaaa48..bb1f3c1e8a 100644 --- a/src/Data/Skills/act_str.lua +++ b/src/Data/Skills/act_str.lua @@ -4142,6 +4142,9 @@ skills["EarthquakePlayer"] = { area = true, duration = true, }, + baseMods = { + skill("cannotCauseAftershock", true), + }, constantStats = { { "skill_jagged_ground_base_duration_ms", 4000 }, { "jagged_ground_effect_+%", 100 }, @@ -4217,9 +4220,12 @@ skills["EarthquakePlayer"] = { attack = true, melee = true, area = true, + duration = true, }, baseMods = { skill("showAverage", true), + skill("duration", 4), + skill("hitRateLimitedByDuration", true), }, constantStats = { { "jagged_ground_effect_+%", 100 }, diff --git a/src/Export/Skills/act_str.txt b/src/Export/Skills/act_str.txt index 2cda812713..649627e55e 100644 --- a/src/Export/Skills/act_str.txt +++ b/src/Export/Skills/act_str.txt @@ -303,9 +303,10 @@ statMap = { mod("AilmentMagnitude", "MORE", nil, 0, bit.bor(KeywordFlag.Poison, KeywordFlag.Bleed, KeywordFlag.Ignite)), }, }, +#baseMod skill("cannotCauseAftershock", true) #mods #set EarthquakeAftershockPlayer -#flags attack melee area +#flags attack melee area duration statMap = { ["jagged_ground_effect_+%"] = { mod("Damage", "MORE", nil, 0, 0), @@ -315,6 +316,8 @@ statMap = { }, }, #baseMod skill("showAverage", true) +#baseMod skill("duration", 4) +#baseMod skill("hitRateLimitedByDuration", true) #mods #skillEnd diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 960a5e16d4..901303189d 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -3008,7 +3008,7 @@ function calcs.offence(env, actor, activeSkill) output.Speed = m_min(output.Speed, 1 / output.Cooldown * output.Repeats) end end - if output.Cooldown and skillFlags.selfCast or skillData.maxHitRatePerEnemy or skillData.hitTimeOverride then + if output.Cooldown and skillFlags.selfCast or skillData.maxHitRatePerEnemy or skillData.hitTimeOverride or skillData.hitRateLimitedByDuration then skillFlags.notAverage = true skillFlags.showAverage = false skillData.showAverage = false @@ -3143,6 +3143,11 @@ function calcs.offence(env, actor, activeSkill) if skillFlags.brand and not skillModList:Flag(nil, "UnlimitedBrandDuration") then output.BrandTicks = m_floor(output.Duration * output.HitSpeed) end + elseif skillData.hitRateLimitedByDuration and output.Time and globalOutput.Duration and globalOutput.Duration > 0 and not skillData.triggeredOnDeath then + -- The hit cannot occur more than once per skill duration on a target (e.g. Earthquake's Aftershock: + -- Jagged Ground cannot be created on top of an existing patch), so the hit rate is capped by the duration + output.HitTime = m_max(output.Time, globalOutput.Duration) + output.HitSpeed = 1 / output.HitTime elseif skillData.hitTimeMultiplier and output.Time and not skillData.triggeredOnDeath then output.HitTime = output.Time * skillData.hitTimeMultiplier if skillFlags.channelRelease and skillData.minChannelTime then @@ -3247,6 +3252,10 @@ function calcs.offence(env, actor, activeSkill) if skillData.hitTimeOverride and not skillData.triggeredOnDeath then output.HitTime = skillData.hitTimeOverride output.HitSpeed = 1 / output.HitTime + elseif skillData.hitRateLimitedByDuration and output.Time and output.Duration and output.Duration > 0 and not skillData.triggeredOnDeath then + -- Recompute from the combined attack time of both weapons when dual wielding + output.HitTime = m_max(output.Time, output.Duration) + output.HitSpeed = 1 / output.HitTime elseif skillData.timeOverride and not skillData.triggeredOnDeath then output.Time = skillData.timeOverride elseif skillData.hitTimeMultiplier and output.Time and not skillData.triggeredOnDeath then @@ -3282,6 +3291,13 @@ function calcs.offence(env, actor, activeSkill) breakdown.HitSpeed = { } t_insert(breakdown.HitSpeed, s_format("1 / %.2f ^8(hit time override)", output.HitTime)) t_insert(breakdown.HitSpeed, s_format("= %.2f", output.HitSpeed)) + elseif skillData.hitRateLimitedByDuration and output.HitSpeed and not skillData.triggeredOnDeath then + breakdown.HitTime = { } + t_insert(breakdown.HitTime, s_format("max(%.2f, %.2f) ^8max(%s time, skill duration)", output.Time, globalOutput.Duration, isAttack and "attack" or "cast")) + t_insert(breakdown.HitTime, s_format("= %.2f", output.HitTime)) + breakdown.HitSpeed = { } + t_insert(breakdown.HitSpeed, s_format("1 / %.2f ^8(hit time)", output.HitTime)) + t_insert(breakdown.HitSpeed, s_format("= %.2f", output.HitSpeed)) elseif skillData.hitTimeMultiplier and output.Time and not skillData.triggeredOnDeath then breakdown.HitTime = { } if m_floor(skillData.hitTimeMultiplier) ~= skillData.hitTimeMultiplier then @@ -4062,7 +4078,16 @@ function calcs.offence(env, actor, activeSkill) output.DoubleDamageEffect = output.DoubleDamageChance / 100 output.ScaledDamageEffect = output.ScaledDamageEffect * (1 + output.DoubleDamageEffect + output.TripleDamageEffect) - output.DpsMultiplier = ( skillData.dpsMultiplier or 1 ) * calcLib.mod(skillModList, skillCfg, "DPS") + -- Calculate chance for slam skills to cause an additional aftershock, which repeats the hit + -- Chance is not capped: each full 100% is a guaranteed aftershock and the remainder is a chance for one more + -- Some slams have a separate stat set for the aftershock (e.g. Earthquake): only that set can cause additional aftershocks + output.AftershockEffect = 1 + if activeSkill.skillTypes[SkillType.Slam] and not skillData.cannotCauseAftershock then + output.AftershockChance = skillModList:Sum("BASE", cfg, "AftershockChance") + output.AftershockEffect = 1 + output.AftershockChance / 100 + end + + output.DpsMultiplier = ( skillData.dpsMultiplier or 1 ) * calcLib.mod(skillModList, skillCfg, "DPS") * output.AftershockEffect local hitRate = output.HitChance / 100 * (globalOutput.HitSpeed or globalOutput.Speed) * output.DpsMultiplier @@ -4728,6 +4753,8 @@ function calcs.offence(env, actor, activeSkill) combineStat("AverageDamage", "DPS") combineStat("PvpAverageDamage", "DPS") combineStat("DpsMultiplier", "DPS") + combineStat("AftershockChance", "AVERAGE") + combineStat("AftershockEffect", "AVERAGE") combineStat("TotalDPS", "DPS") combineStat("PvpTotalDPS", "DPS") combineStat("LifeLeechDuration", "DPS") @@ -4872,8 +4899,13 @@ function calcs.offence(env, actor, activeSkill) output.HitSpeed and s_format("x %.2f ^8(hit rate)", output.HitSpeed) or s_format("x %.2f ^8(cast rate)", output.Speed), } end - if output.DpsMultiplier ~= 1 then - t_insert(breakdown.TotalDPS, s_format("x %g ^8(DPS multiplier for this skill)", output.DpsMultiplier)) + local dpsMultiplier = output.DpsMultiplier + if output.AftershockEffect and output.AftershockEffect ~= 1 then + t_insert(breakdown.TotalDPS, s_format("x %.2f ^8(%d%% chance to cause an additional aftershock)", output.AftershockEffect, output.AftershockChance)) + dpsMultiplier = round(dpsMultiplier / output.AftershockEffect, 4) + end + if dpsMultiplier ~= 1 then + t_insert(breakdown.TotalDPS, s_format("x %g ^8(DPS multiplier for this skill)", dpsMultiplier)) end if quantityMultiplier > 1 then t_insert(breakdown.TotalDPS, s_format("x %g ^8(quantity multiplier for this skill)", quantityMultiplier)) diff --git a/src/Modules/CalcSections.lua b/src/Modules/CalcSections.lua index e5604958c8..7273df86c5 100644 --- a/src/Modules/CalcSections.lua +++ b/src/Modules/CalcSections.lua @@ -718,6 +718,7 @@ return { { label = "Rage Cost", color = colorCodes.RAGE, haveOutput = "RageHasCost", { format = "{0:output:RageCost}", { breakdown = "RageCost" }, { modName = { "RageCost", "Cost", "RageNoMult" }, cfg = "skill" }, }, }, { label = "Rage per second", color = colorCodes.RAGE, haveOutput = "RagePerSecondHasCost", { format = "{2:output:RagePerSecondCost}", { breakdown = "RagePerSecondCost" }, { modName = { "RageCost", "Cost", "RageNoMult" }, cfg = "skill" }, }, }, { label = "Armour Break / hit", haveOutput = "ArmourBreakPerHit", { format = "{0:output:ArmourBreakPerHit}", { modName = "ArmourBreakPerHit", modType = "BASE"} }, }, + { label = "Aftershock Chance", haveOutput = "AftershockChance", { format = "{0:output:AftershockChance}%", { modName = "AftershockChance", cfg = "skill" }, }, }, { label = "Soul Cost", color = colorCodes.RAGE, haveOutput = "SoulHasCost", { format = "{0:output:SoulCost}", { breakdown = "SoulCost" }, { modName = { "SoulCost" }, cfg = "skill" }, }, }, { label = "Active Minion Limit", haveOutput = "ActiveMinionLimit", { format = "{0:output:ActiveMinionLimit}", { breakdown = "ActiveMinionLimit" }, diff --git a/src/Modules/ModParser.lua b/src/Modules/ModParser.lua index 6d8d909dcb..64d8691488 100644 --- a/src/Modules/ModParser.lua +++ b/src/Modules/ModParser.lua @@ -2702,6 +2702,11 @@ local specialModList = { flag("AncestralEmpowerment", { type = "SkillType", skillType = SkillType.Slam }), flag("Condition:AncestrallyBoosted", { type = "SkillType", skillType = SkillType.Slam }), }, + -- Aftershocks + ["(%d+)%% chance for slam skills to cause an additional aftershock"] = function(num) return { mod("AftershockChance", "BASE", num, { type = "SkillType", skillType = SkillType.Slam }) } end, + ["(%d+)%% chance for slam skills you use yourself to cause an additional aftershock"] = function(num) return { mod("AftershockChance", "BASE", num, { type = "SkillType", skillType = SkillType.Slam }) } end, -- Earthbreaker (Titan Ascendancy) + ["(%d+)%% chance for mace slam skills you use yourself to cause an additional aftershock"] = function(num) return { mod("AftershockChance", "BASE", num, nil, ModFlag.Mace, 0, { type = "SkillType", skillType = SkillType.Slam }) } end, + ["(%d+)%% chance for shapeshift slam skills you use yourself to cause an additional aftershock"] = function(num) return { mod("AftershockChance", "BASE", num, { type = "SkillType", skillType = SkillType.Slam }, { type = "SkillType", skillType = SkillType.Shapeshift }) } end, -- Leech Related ["life leech is instant"] = { mod("InstantLifeLeech", "BASE", 100), }, ["mana leech is instant"] = { mod("InstantManaLeech", "BASE", 100), }, From 46750af83d64bb02b6d8c0d2fe88a11eda2f0ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=A0i=C5=A1ka?= Date: Wed, 23 Sep 2026 20:06:08 +0200 Subject: [PATCH 2/2] Round Earthquake's Aftershock time up to a whole number of attacks Jagged Ground cannot be created on top of an existing patch. An attack that lands before the previous patch erupts creates nothing, so the next patch (and its Aftershock) comes from the first attack at or after the eruption. The time between Aftershocks is therefore the attack time multiplied by ceil(patch duration / attack time), not the larger of the two. Example: a 0.40s patch with a 0.39s attack time wastes every other attack and erupts every 0.78s, where the previous model gave 0.40s. Attacking slightly faster than the patch duration now lowers Aftershock DPS instead of leaving it unchanged, and reduced duration only pays when it removes a wasted attack. The Impact hit still lands on every attack. The dual-wield recompute uses the same rule on the combined attack time, and the Hit Time breakdown shows the attacks per Aftershock. Co-Authored-By: Claude Opus 5.5 --- spec/System/TestEarthquake_spec.lua | 61 +++++++++++++++++++++-------- src/Modules/CalcOffence.lua | 25 +++++++++--- 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/spec/System/TestEarthquake_spec.lua b/spec/System/TestEarthquake_spec.lua index c8c2871b38..690b93fe5f 100644 --- a/spec/System/TestEarthquake_spec.lua +++ b/spec/System/TestEarthquake_spec.lua @@ -28,33 +28,62 @@ describe("TestEarthquake", function() assert.is_true(math.abs(expected - actual) < 0.001, string.format("expected %s, got %s", tostring(expected), tostring(actual))) end - it("caps the Aftershock hit rate at once per Jagged Ground duration", function() + -- Jagged Ground cannot be created on top of an existing patch: an attack that lands before the previous patch + -- erupts creates nothing, so the next patch (and its Aftershock) comes from the first attack at or after the eruption + local function expectedHitTime(out) + local attackTime = 1 / out.Speed + return attackTime * math.max(math.ceil(out.Duration / attackTime - 1e-9), 1) + end + + it("fires the Aftershock on every Nth attack, N being the attack times needed to cover the patch duration", function() local out = setupEarthquake(2) assert.is_true(out.Duration > 0) assert.is_true(out.Speed > 1 / out.Duration, "test assumes attack time is shorter than the patch duration") - assertClose(1 / out.Duration, out.HitSpeed) + assertClose(expectedHitTime(out), out.HitTime) + assertClose(1 / out.HitTime, out.HitSpeed) + assert.is_true(out.HitTime >= out.Duration - 0.001, "no patch can be created before the previous one erupts") + assert.is_true(out.HitTime - 1 / out.Speed < out.Duration, "the first attack after the eruption creates the next patch") assertClose(out.AverageDamage * out.HitSpeed * out.DpsMultiplier, out.TotalDPS) assert.is_falsy(build.calcsTab.mainEnv.player.mainSkill.skillData.showAverage) end) - it("does not scale Aftershock DPS with attack speed while the patch duration is the limit", function() - local base = setupEarthquake(2) + it("rounds the Aftershock time up to a whole number of attacks for any patch duration", function() + for _, reduced in ipairs({ 30, 50, 70, 80, 90 }) do + newBuild() + local out = setupEarthquake(2, reduced .. "% reduced Skill Effect Duration") + assertClose(expectedHitTime(out), out.HitTime) + assertClose(out.AverageDamage / out.HitTime * out.DpsMultiplier, out.TotalDPS) + end + end) + it("wastes every other attack when the patch outlasts the attack time", function() + local base = setupEarthquake(2) + local attackTime = 1 / base.Speed + -- shorten the patch to 1.5 attack times: the second attack lands on the live patch and creates nothing + local reduced = math.floor(100 * (1 - 1.5 * attackTime / base.Duration)) newBuild() - local out = setupEarthquake(2, "200% increased Attack Speed") - assert.is_true(out.Speed > base.Speed) - assertClose(base.HitSpeed, out.HitSpeed) - assertClose(base.TotalDPS, out.TotalDPS) + local out = setupEarthquake(2, reduced .. "% reduced Skill Effect Duration") + assert.is_true(out.Duration > attackTime and out.Duration < 2 * attackTime, "test assumes the patch lasts between one and two attack times") + assertClose(2 * attackTime, out.HitTime) + assertClose(base.Speed / 2, out.HitSpeed) end) - it("scales Aftershock DPS with reduced skill effect duration", function() + it("lowers Aftershock DPS when attacking slightly faster than the patch duration", function() local base = setupEarthquake(2) - + local attackTime = 1 / base.Speed + -- patch just shorter than the attack time: every attack creates a patch + local reduced = math.ceil(100 * (1 - 0.95 * attackTime / base.Duration)) + newBuild() + local slow = setupEarthquake(2, reduced .. "% reduced Skill Effect Duration") + assert.is_true(slow.Duration <= 1 / slow.Speed, "test assumes the patch erupts before the next attack") + assertClose(slow.Speed, slow.HitSpeed) + -- 10% more attack speed makes the next attack land on the live patch, halving the Aftershock rate newBuild() - local out = setupEarthquake(2, "50% reduced Skill Effect Duration") - assertClose(base.Duration / 2, out.Duration) - assertClose(1 / out.Duration, out.HitSpeed) - assertClose(base.TotalDPS * 2, out.TotalDPS) + local fast = setupEarthquake(2, reduced .. "% reduced Skill Effect Duration\n10% increased Attack Speed") + assert.is_true(fast.Speed > slow.Speed) + assert.is_true(fast.Duration > 1 / fast.Speed, "test assumes the next attack now lands before the eruption") + assertClose(fast.Speed / 2, fast.HitSpeed) + assert.is_true(fast.TotalDPS < slow.TotalDPS) end) it("uses the attack rate for Aftershock once the patch duration is shorter than the attack time", function() @@ -69,11 +98,11 @@ describe("TestEarthquake", function() assertClose(out.AverageDamage * out.Speed * out.DpsMultiplier, out.TotalDPS) end) - it("caps the Aftershock hit rate using the combined attack time when dual wielding", function() + it("rounds the Aftershock time using the combined attack time when dual wielding", function() local weapons = { "Marauding Mace\n20% increased Attack Speed", "Marauding Mace" } local out = setupEarthquake(2, nil, weapons) assert.is_true(out.MainHand.Speed ~= out.OffHand.Speed, "test assumes the two weapons attack at different speeds") - assertClose(1 / out.Duration, out.HitSpeed) + assertClose(expectedHitTime(out), out.HitTime) assertClose(out.AverageDamage * out.HitSpeed * out.DpsMultiplier, out.TotalDPS) newBuild() diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 901303189d..4f82492117 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -432,6 +432,14 @@ function calcSkillCooldown(skillModList, skillCfg, skillData) end end +-- Time between hits of a skill that can only hit once per duration-long effect that cannot be refreshed early +-- (e.g. Earthquake's Aftershock: Jagged Ground cannot be created on top of an existing patch). A use that lands +-- before the previous effect ends creates nothing, so the next effect comes from the first use at or after the end +local function calcDurationLimitedHitTime(useTime, duration) + local usesPerHit = m_max(m_ceil(duration / useTime - 1e-9), 1) + return useTime * usesPerHit, usesPerHit +end + local function calcWarcryCastTime(skillModList, skillCfg, skillData, actor) local baseSpeed = 1 / skillModList:Sum("BASE", skillCfg, "WarcryCastTime") local warcryCastTime = baseSpeed * calcLib.mod(skillModList, skillCfg, "WarcrySpeed") * calcs.actionSpeedMod(actor) @@ -3144,9 +3152,8 @@ function calcs.offence(env, actor, activeSkill) output.BrandTicks = m_floor(output.Duration * output.HitSpeed) end elseif skillData.hitRateLimitedByDuration and output.Time and globalOutput.Duration and globalOutput.Duration > 0 and not skillData.triggeredOnDeath then - -- The hit cannot occur more than once per skill duration on a target (e.g. Earthquake's Aftershock: - -- Jagged Ground cannot be created on top of an existing patch), so the hit rate is capped by the duration - output.HitTime = m_max(output.Time, globalOutput.Duration) + -- Only every Nth use creates the effect that hits (e.g. Earthquake's Jagged Ground), see calcDurationLimitedHitTime + output.HitTime, output.UsesPerHit = calcDurationLimitedHitTime(output.Time, globalOutput.Duration) output.HitSpeed = 1 / output.HitTime elseif skillData.hitTimeMultiplier and output.Time and not skillData.triggeredOnDeath then output.HitTime = output.Time * skillData.hitTimeMultiplier @@ -3254,7 +3261,7 @@ function calcs.offence(env, actor, activeSkill) output.HitSpeed = 1 / output.HitTime elseif skillData.hitRateLimitedByDuration and output.Time and output.Duration and output.Duration > 0 and not skillData.triggeredOnDeath then -- Recompute from the combined attack time of both weapons when dual wielding - output.HitTime = m_max(output.Time, output.Duration) + output.HitTime, output.UsesPerHit = calcDurationLimitedHitTime(output.Time, output.Duration) output.HitSpeed = 1 / output.HitTime elseif skillData.timeOverride and not skillData.triggeredOnDeath then output.Time = skillData.timeOverride @@ -3293,8 +3300,14 @@ function calcs.offence(env, actor, activeSkill) t_insert(breakdown.HitSpeed, s_format("= %.2f", output.HitSpeed)) elseif skillData.hitRateLimitedByDuration and output.HitSpeed and not skillData.triggeredOnDeath then breakdown.HitTime = { } - t_insert(breakdown.HitTime, s_format("max(%.2f, %.2f) ^8max(%s time, skill duration)", output.Time, globalOutput.Duration, isAttack and "attack" or "cast")) - t_insert(breakdown.HitTime, s_format("= %.2f", output.HitTime)) + local useName = isAttack and "attack" or "cast" + local usesPerHit = output.UsesPerHit or 1 + t_insert(breakdown.HitTime, s_format("%.3f ^8(%s time)", output.Time, useName)) + t_insert(breakdown.HitTime, s_format("x %d ^8(%ss per hit: ceil(%.3f skill duration / %.3f %s time))", usesPerHit, useName, globalOutput.Duration, output.Time, useName)) + if usesPerHit > 1 then + t_insert(breakdown.HitTime, s_format("^8(a %s landing before the previous effect ends creates no new one)", useName)) + end + t_insert(breakdown.HitTime, s_format("= %.3f", output.HitTime)) breakdown.HitSpeed = { } t_insert(breakdown.HitSpeed, s_format("1 / %.2f ^8(hit time)", output.HitTime)) t_insert(breakdown.HitSpeed, s_format("= %.2f", output.HitSpeed))