Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 87 additions & 1 deletion spec/System/TestSkills_spec.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
describe("TestAttacks", function()
describe("TestSkills", function()
before_each(function()
newBuild()
end)
Expand Down Expand Up @@ -365,4 +365,90 @@ describe("TestAttacks", function()
assert.are.equals(0, nonDurationSkill.skillModList:Sum("BASE", nonDurationSkill.skillCfg, "BaseFlagTest"))
assert.are.equals(1, nonDurationSkill.skillModList:Sum("BASE", nonDurationSkill.skillCfg, "NegatedBaseFlagTest"))
end)

it("applies Pact of Beidat coverage based on uptime", function()
for _, test in ipairs({
{ skill = "Fireball", output = "ProjectileCount", bonus = "BeidatAdditionalProjectiles", amount = 4 },
{ skill = "Arc", output = "ChainMax", bonus = "BeidatAdditionalBeamChains", amount = 4 },
{ skill = "Firestorm", bonus = "BeidatAdditionalCascades", amount = 5 },
}) do
newBuild()
build.skillsTab:PasteSocketGroup(test.skill.." 20/0 1")
runCallback("OnFrame")
local baseOutput = test.output and build.calcsTab.mainOutput[test.output]
local baseDPS = build.calcsTab.mainOutput.TotalDPS

build.skillsTab:PasteSocketGroup("Pact of Beidat 1/0 1")
runCallback("OnFrame")
local output = build.calcsTab.mainOutput
local expectedBonus = test.amount * output.BeidatUpTimeRatio / 100
assert.are.near(expectedBonus, output[test.bonus], 10 ^ -9)
if test.output then
assert.are.near(baseOutput + expectedBonus, output[test.output], 10 ^ -9)
end
assert.is_true(output.TotalDPS > baseDPS)
end

newBuild()
build.skillsTab:PasteSocketGroup("Fireball 20/0 1\nPact of Beidat 1/0 1")
build.configTab.input.pactMode = "MAX"
build.configTab:BuildModList()
runCallback("OnFrame")
assert.are.equals(4, build.calcsTab.mainOutput.BeidatAdditionalProjectiles)
end)

it("applies Pact damage to eligible spells", function()
for _, test in ipairs({
{ skill = "Creeping Frost", pact = "Pact of Ghorr", uptime = "GhorrUpTimeRatio", dps = "TotalDotDPS" },
{ skill = "Lightning Tendrils", pact = "Pact of Lycia", uptime = "LyciaUpTimeRatio", dps = "TotalDPS" },
}) do
newBuild()
build.skillsTab:PasteSocketGroup(test.skill.." 20/0 1")
runCallback("OnFrame")
local baseDPS = build.calcsTab.mainOutput[test.dps]
build.skillsTab:PasteSocketGroup(test.pact.." 1/0 1")
runCallback("OnFrame")
local output = build.calcsTab.mainOutput
assert.is_true(output[test.uptime] > 0)
assert.is_true(output[test.dps] > baseDPS)
end

newBuild()
build.skillsTab:PasteSocketGroup("Lightning Tendrils 20/0 1\nSpell Totem 20/0 1")
runCallback("OnFrame")
local baseDPS = build.calcsTab.mainOutput.TotalDPS
build.skillsTab:PasteSocketGroup("Pact of Lycia 1/0 1")
runCallback("OnFrame")
assert.is_nil(build.calcsTab.mainOutput.LyciaUpTimeRatio)
assert.are.equals(baseDPS, build.calcsTab.mainOutput.TotalDPS)
end)

it("applies Pact of K'Tash to instant damaging Vaal spells", function()
build.skillsTab:PasteSocketGroup("Vaal Righteous Fire 20/0 1")
runCallback("OnFrame")
local baseDPS = build.calcsTab.mainOutput.TotalDotDPS
local basePrevention = build.calcsTab.mainOutput.SoulGainPreventionDuration

build.skillsTab:PasteSocketGroup("Pact of K'Tash 1/0 1")
runCallback("OnFrame")
local output = build.calcsTab.mainOutput
assert.are.equals(100, output.KtashUpTimeRatio)
assert.are.near(baseDPS * 1.1, output.TotalDotDPS, 10 ^ -6)
assert.are.equals(100, output.KtashSoulRefundChance)
assert.are.equals(math.max(math.ceil(basePrevention * 0.5 * data.misc.ServerTickRate), 1) / data.misc.ServerTickRate, output.SoulGainPreventionDuration)
end)

it("does not apply Pacts to ineligible spells", function()
for _, test in ipairs({
{ "Vaal Arc", "Pact of Beidat", "BeidatUpTimeRatio" },
{ "Vaal Arc", "Pact of Ghorr", "GhorrUpTimeRatio" },
{ "Fireball", "Pact of K'Tash", "KtashUpTimeRatio" },
{ "Fireball", "Pact of Lycia", "LyciaUpTimeRatio" },
}) do
newBuild()
build.skillsTab:PasteSocketGroup(test[1].." 20/0 1\n"..test[2].." 1/0 1")
runCallback("OnFrame")
assert.is_nil(build.calcsTab.mainOutput[test[3]])
end
end)
end)
3 changes: 3 additions & 0 deletions src/Data/SkillStatMap.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2368,4 +2368,7 @@ return {
["is_totem"] = {
-- Display only
},
["pact_empower_limitation_specifier_for_stat_description"] = {
-- Display only
},
}
62 changes: 62 additions & 0 deletions src/Data/Skills/other.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2778,7 +2778,26 @@ skills["PactOfBeidat"] = {
skillTypes = { [SkillType.Spell] = true, [SkillType.Pact] = true, [SkillType.Buff] = true, [SkillType.Cooldown] = true, [SkillType.InstantNoRepeatWhenHeld] = true, [SkillType.InstantShiftAttackForLeftMouse] = true, [SkillType.Triggerable] = true, },
statDescriptionScope = "skill_stat_descriptions",
castTime = 0.8,
statMap = {
["skill_empowers_next_x_spells_cast"] = {
mod("BeidatEmpoweredSpells", "BASE", nil),
},
["pact_skill_additional_beam_only_chains"] = {
mod("BeidatAdditionalBeamChains", "BASE", nil),
},
["pact_skill_grant_x_additional_projectiles_fired_in_nova"] = {
mod("BeidatAdditionalProjectiles", "BASE", nil),
},
["pact_skill_grant_x_cascades_to_do_in_spiral"] = {
mod("BeidatAdditionalCascades", "BASE", nil),
},
["pact_skill_damage_+%_final_with_hits_and_ailments_to_grant"] = {
mod("BeidatPactDamage", "LIST", { mod = mod("Damage", "MORE", nil, ModFlag.Spell, bit.bor(KeywordFlag.Hit, KeywordFlag.Ailment)) }),
},
},
baseFlags = {
spell = true,
pact = true,
},
qualityStats = {
{ "skill_empowers_next_x_spells_cast", 0.1 },
Expand Down Expand Up @@ -2849,7 +2868,18 @@ skills["PactOfGhorr"] = {
skillTypes = { [SkillType.Spell] = true, [SkillType.Pact] = true, [SkillType.Buff] = true, [SkillType.Cooldown] = true, [SkillType.InstantNoRepeatWhenHeld] = true, [SkillType.InstantShiftAttackForLeftMouse] = true, [SkillType.Triggerable] = true, },
statDescriptionScope = "skill_stat_descriptions",
castTime = 0.8,
statMap = {
["skill_empowers_next_x_spells_cast"] = {
mod("GhorrEmpoweredSpells", "BASE", nil),
},
["pact_skill_grant_damage_over_time_+%_final_to_spells"] = {
mod("GhorrPactDamage", "LIST", { mod = mod("Damage", "MORE", nil, ModFlag.Dot) }),
},
},
baseFlags = {
spell = true,
pact = true,
duration = true,
},
constantStats = {
{ "skill_empowers_next_x_spells_cast", 3 },
Expand Down Expand Up @@ -2920,6 +2950,9 @@ skills["TriggeredBloodrend"] = {
statDescriptionScope = "debuff_skill_stat_descriptions",
castTime = 0,
baseFlags = {
spell = true,
pact = true,
projectile = true,
},
qualityStats = {
{ "skill_effect_duration_+%", 1 },
Expand Down Expand Up @@ -2990,7 +3023,23 @@ skills["PactOfKtash"] = {
skillTypes = { [SkillType.Spell] = true, [SkillType.Pact] = true, [SkillType.Buff] = true, [SkillType.Cooldown] = true, [SkillType.InstantNoRepeatWhenHeld] = true, [SkillType.InstantShiftAttackForLeftMouse] = true, [SkillType.Triggerable] = true, },
statDescriptionScope = "skill_stat_descriptions",
castTime = 0.8,
statMap = {
["skill_empowers_next_x_spells_cast"] = {
mod("KtashEmpoweredSpells", "BASE", nil),
},
["pact_skill_grant_damage_+%_final_to_exerted_skills"] = {
mod("KtashPactDamage", "LIST", { mod = mod("Damage", "MORE", nil) }),
},
["active_skill_osm_vaal_skill_soul_gain_prevention_+%_final_to_grant"] = {
mod("KtashPactSoulGainPrevention", "BASE", nil),
},
["active_skill_osm_vaal_skill_soul_refund_chance_%_to_grant"] = {
mod("KtashPactSoulRefundChance", "BASE", nil),
},
},
baseFlags = {
spell = true,
pact = true,
},
qualityStats = {
{ "base_cooldown_speed_+%", 0.5 },
Expand Down Expand Up @@ -3060,7 +3109,17 @@ skills["PactOfLycia"] = {
skillTypes = { [SkillType.Spell] = true, [SkillType.Pact] = true, [SkillType.Buff] = true, [SkillType.Cooldown] = true, [SkillType.InstantNoRepeatWhenHeld] = true, [SkillType.InstantShiftAttackForLeftMouse] = true, [SkillType.Triggerable] = true, },
statDescriptionScope = "skill_stat_descriptions",
castTime = 0.8,
statMap = {
["skill_empowers_next_x_spells_cast"] = {
mod("LyciaEmpoweredSpells", "BASE", nil),
},
["pact_skill_grant_damage_+%_final_to_exerted_skills"] = {
mod("LyciaPactDamage", "LIST", { mod = mod("Damage", "MORE", nil) }),
},
},
baseFlags = {
spell = true,
pact = true,
},
constantStats = {
{ "skill_empowers_next_x_spells_cast", 3 },
Expand Down Expand Up @@ -3128,6 +3187,9 @@ skills["TriggeredHeavensScourge"] = {
statDescriptionScope = "skill_stat_descriptions",
castTime = 1,
baseFlags = {
spell = true,
pact = true,
area = true,
},
qualityStats = {
{ "active_skill_base_area_of_effect_radius", 0.1 },
Expand Down
53 changes: 53 additions & 0 deletions src/Export/Skills/other.txt
Original file line number Diff line number Diff line change
Expand Up @@ -741,21 +741,74 @@ local skills, mod, flag, skill = ...
#mods

#skill PactOfBeidat
#flags spell pact
statMap = {
["skill_empowers_next_x_spells_cast"] = {
mod("BeidatEmpoweredSpells", "BASE", nil),
},
["pact_skill_additional_beam_only_chains"] = {
mod("BeidatAdditionalBeamChains", "BASE", nil),
},
["pact_skill_grant_x_additional_projectiles_fired_in_nova"] = {
mod("BeidatAdditionalProjectiles", "BASE", nil),
},
["pact_skill_grant_x_cascades_to_do_in_spiral"] = {
mod("BeidatAdditionalCascades", "BASE", nil),
},
["pact_skill_damage_+%_final_with_hits_and_ailments_to_grant"] = {
mod("BeidatPactDamage", "LIST", { mod = mod("Damage", "MORE", nil, ModFlag.Spell, bit.bor(KeywordFlag.Hit, KeywordFlag.Ailment)) }),
},
},
#mods

#skill PactOfGhorr
#flags spell pact duration
statMap = {
["skill_empowers_next_x_spells_cast"] = {
mod("GhorrEmpoweredSpells", "BASE", nil),
},
["pact_skill_grant_damage_over_time_+%_final_to_spells"] = {
mod("GhorrPactDamage", "LIST", { mod = mod("Damage", "MORE", nil, ModFlag.Dot) }),
},
},
#mods

#skill TriggeredBloodrend
#flags spell pact projectile
#mods

#skill PactOfKtash
#flags spell pact
statMap = {
["skill_empowers_next_x_spells_cast"] = {
mod("KtashEmpoweredSpells", "BASE", nil),
},
["pact_skill_grant_damage_+%_final_to_exerted_skills"] = {
mod("KtashPactDamage", "LIST", { mod = mod("Damage", "MORE", nil) }),
},
["active_skill_osm_vaal_skill_soul_gain_prevention_+%_final_to_grant"] = {
mod("KtashPactSoulGainPrevention", "BASE", nil),
},
["active_skill_osm_vaal_skill_soul_refund_chance_%_to_grant"] = {
mod("KtashPactSoulRefundChance", "BASE", nil),
},
},
#mods

#skill PactOfLycia
#flags spell pact
statMap = {
["skill_empowers_next_x_spells_cast"] = {
mod("LyciaEmpoweredSpells", "BASE", nil),
},
["pact_skill_grant_damage_+%_final_to_exerted_skills"] = {
mod("LyciaPactDamage", "LIST", { mod = mod("Damage", "MORE", nil) }),
},
},
#mods

#skill TriggeredHeavensScourge
#flags spell pact area
#mods

#skill Portal
Expand Down
84 changes: 84 additions & 0 deletions src/Modules/CalcOffence.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2800,6 +2800,90 @@ function calcs.offence(env, actor, activeSkill)
globalOutput.ExertedAttackUptimeRatioCalculated = true
end
end

-- Each Pact has different eligible spell types, but shares the same uptime calculation.
if activeSkill.skillTypes[SkillType.Spell] and not activeSkill.skillTypes[SkillType.Brand] then
for _, value in ipairs(actor.activeSkillList) do
local pactName = value.activeEffect.grantedEffect.name
local pactKey = pactName:match("^Pact of (.+)$")
if pactKey then
pactKey = pactKey == "K'Tash" and "Ktash" or pactKey
local isVaal = activeSkill.skillTypes[SkillType.Vaal]
local pactApplies = pactKey == "Beidat" and not isVaal and not activeSkill.skillTypes[SkillType.Channel]
and (skillFlags.projectile or activeSkill.skillTypes[SkillType.Cascadable] or skillFlags.chaining and not skillFlags.projectile)
or pactKey == "Ghorr" and not isVaal and activeSkill.skillTypes[SkillType.DamageOverTime]
or pactKey == "Ktash" and isVaal and (activeSkill.skillTypes[SkillType.Damage] or activeSkill.skillTypes[SkillType.DamageOverTime])
or pactKey == "Lycia" and not isVaal and activeSkill.skillTypes[SkillType.Channel] and skillFlags.selfCast
local calculated = "PactOf"..pactKey.."Calculated"
if pactApplies and not globalOutput[calculated] and not value.skillFlags.disable then
local cooldown = calcSkillCooldown(value.skillModList, value.skillCfg, value.skillData)
local castRate = 1 / value.activeEffect.grantedEffect.castTime * calcLib.mod(value.skillModList, value.skillCfg, "Speed") * calcs.actionSpeedMod(actor)
local castTime = 1 / m_min(castRate, data.misc.ServerTickRate)
local count = value.skillModList:Sum("BASE", value.skillCfg, pactKey.."EmpoweredSpells")
local storedUses = value.skillData.storedUses + value.skillModList:Sum("BASE", value.skillCfg, "AdditionalCooldownUses")
local uptime = (globalOutput.Speed or 0) == 0 and 100 or m_min((count / globalOutput.Speed) / (cooldown + castTime), 1) * 100
uptime = m_min(100, uptime * storedUses)
local effect = skillModList:Flag(nil, "Condition:PactMaxHit") and 100 or uptime
local effectMult = effect / 100

globalOutput.CreatePactOffensiveCalcSection = true
globalOutput["PactOf"..pactKey.."Cooldown"] = cooldown
globalOutput["PactOf"..pactKey.."CastTime"] = castTime
globalOutput[pactKey.."EmpoweredCount"] = count
globalOutput[pactKey.."UpTimeRatio"] = uptime
if globalBreakdown then
globalBreakdown[pactKey.."UpTimeRatio"] = {
s_format("(%.2f ^8(number of Empowered)", count),
s_format("/ %.2f) ^8(casts per second)", globalOutput.Speed or 0),
s_format("/ (%.2f ^8(pact cooldown)", cooldown),
s_format("+ %.2f) ^8(pact cast time)", castTime),
s_format("* %.2f ^8(stored uses)", storedUses),
s_format("= %.2f%%", uptime),
}
end

local damage = value.skillModList:List(value.skillCfg, pactKey.."PactDamage")[1]
if damage then
local mod = damage.mod
skillModList:NewMod(mod.name, mod.type, mod.value * effectMult, "Uptime Scaled "..pactName, mod.flags, mod.keywordFlags, unpack(mod))
end

if pactKey == "Beidat" then
-- Coverage bonuses are averaged with the same uptime as the damage bonus.
if skillFlags.projectile and not skillModList:Flag(skillCfg, "NoAdditionalProjectiles") and not skillModList:Flag(skillCfg, "SingleProjectile") then
globalOutput.BeidatAdditionalProjectiles = value.skillModList:Sum("BASE", value.skillCfg, "BeidatAdditionalProjectiles") * effectMult
globalOutput.ProjectileCount = globalOutput.ProjectileCount + globalOutput.BeidatAdditionalProjectiles
end
if skillFlags.chaining and not skillFlags.projectile and not skillModList:Flag(skillCfg, "CannotChain") and not skillModList:Flag(skillCfg, "NoAdditionalChains") then
globalOutput.BeidatAdditionalBeamChains = value.skillModList:Sum("BASE", value.skillCfg, "BeidatAdditionalBeamChains") * effectMult
globalOutput.ChainMax = globalOutput.ChainMax + globalOutput.BeidatAdditionalBeamChains
globalOutput.ChainMaxString = globalOutput.ChainMax
globalOutput.ChainRemaining = m_max(0, globalOutput.ChainMax - globalOutput.Chain)
end
if activeSkill.skillTypes[SkillType.Cascadable] then
globalOutput.BeidatAdditionalCascades = value.skillModList:Sum("BASE", value.skillCfg, "BeidatAdditionalCascades") * effectMult
end
elseif pactKey == "Ktash" then
globalOutput.KtashSoulRefundChance = value.skillModList:Sum("BASE", value.skillCfg, "KtashPactSoulRefundChance") * effectMult
if globalOutput.SoulGainPreventionDuration then
local duration = globalOutput.SoulGainPreventionDuration
local durationMod = 1 + value.skillModList:Sum("BASE", value.skillCfg, "KtashPactSoulGainPrevention") * effectMult / 100
globalOutput.SoulGainPreventionDuration = m_max(m_ceil(duration * durationMod * data.misc.ServerTickRate), 1) / data.misc.ServerTickRate
if globalBreakdown then
globalBreakdown.SoulGainPreventionDuration = {
s_format("%.3fs ^8(before Pact of K'Tash)", duration),
s_format("x %.4f ^8(average Pact modifier)", durationMod),
"rounded up to nearest server tick",
s_format("= %.3fs", globalOutput.SoulGainPreventionDuration),
}
end
end
end
globalOutput[calculated] = true
end
end
end
end
end

output.RuthlessBlowHitEffect = 1
Expand Down
Loading
Loading