diff --git a/Projects/UOContent.Tests/Tests/Spells/Spellweaving/ArcaneEmpowermentSpellTests.cs b/Projects/UOContent.Tests/Tests/Spells/Spellweaving/ArcaneEmpowermentSpellTests.cs new file mode 100644 index 000000000..705087dd4 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Spells/Spellweaving/ArcaneEmpowermentSpellTests.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Server; +using Server.Items; +using Server.Mobiles; +using Server.Spells; +using Server.Spells.Spellweaving; +using Xunit; + +namespace Server.Tests.Spells.Spellweaving; + +[Collection("Sequential UOContent Tests")] +public class ArcaneEmpowermentSpellTests +{ + [Fact] + public void SpellMetadata_MatchesArcaneEmpowermentSources() + { + var caster = NewCaster(); + var spell = new ArcaneEmpowermentSpell(caster); + + Assert.Equal("Arcane Empowerment", spell.Name); + Assert.Equal("Aslavdra", spell.Mantra); + Assert.Equal(TimeSpan.FromSeconds(4.0), spell.CastDelayBase); + Assert.Equal(24.0, spell.RequiredSkill); + Assert.Equal(50, spell.GetMana()); + Assert.Equal(SkillName.Spellweaving, spell.CastSkill); + Assert.Equal(SkillName.Spellweaving, spell.DamageSkill); + + caster.Delete(); + } + + [Fact] + public void Formula_UsesSkillAndFocusForDurationAndBonuses() + { + Assert.Equal(TimeSpan.FromSeconds(20), ArcaneEmpowermentSpell.GetDuration(1200, 0)); + Assert.Equal(TimeSpan.FromSeconds(24), ArcaneEmpowermentSpell.GetDuration(1200, 2)); + Assert.Equal(10, ArcaneEmpowermentSpell.GetBaseBonus(1200)); + Assert.Equal(20, ArcaneEmpowermentSpell.GetSpellDamageBonus(1200, 2, false)); + Assert.Equal(12, ArcaneEmpowermentSpell.GetSpellDamageBonus(1200, 2, true)); + Assert.Equal(20, ArcaneEmpowermentSpell.GetHealingBonus(1200, 2)); + Assert.Equal(20, ArcaneEmpowermentSpell.GetDispelDifficultyBonus(2)); + } + + [Fact] + public void SpellweavingBookAndScroll_UseArcaneEmpowermentSlot() + { + var book = new SpellweavingBook(1UL << (615 - 600)); + var scroll = new ArcaneEmpowermentScroll(); + + Assert.Equal(600, book.BookOffset); + Assert.Equal(16, book.BookCount); + Assert.True(book.HasSpell(615)); + Assert.Equal(615, GetSpellScrollId(scroll)); + + book.Delete(); + scroll.Delete(); + } + + [Fact] + public void CheckCast_EnforcesRequiredSkillAndMana() + { + var caster = NewCaster(23.9); + var spell = new ArcaneEmpowermentSpell(caster); + + caster.Mana = 100; + Assert.False(spell.CheckCast()); + + caster.Skills.Spellweaving.Base = 24.0; + caster.Mana = 49; + Assert.False(spell.CheckCast()); + + caster.Mana = 100; + Assert.True(spell.CheckCast()); + + caster.Delete(); + } + + [Fact] + public void RegisterSpellweaving_PreMlDoesNotExposeArcaneEmpowerment() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.SE; + Initializer.Configure(); + + Assert.Null(SpellRegistry.NewSpell(615, caster, null)); + Assert.Equal(-1, SpellRegistry.GetRegistryNumber(typeof(ArcaneEmpowermentSpell))); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void RegisterSpellweaving_MlExposesArcaneEmpowermentAtSpellId615() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.ML; + Initializer.Configure(); + + Assert.Same(typeof(ArcaneEmpowermentSpell), SpellRegistry.Types[615]); + Assert.Equal(615, SpellRegistry.GetRegistryNumber(typeof(ArcaneEmpowermentSpell))); + Assert.IsType(SpellRegistry.NewSpell(615, caster, null)); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void Effect_AppliesDamageHealingSummonHealthAndDispelBonusesWithoutStacking() + { + var caster = NewCaster(); + var target = NewTarget(); + var follower = new TestSummon + { + HitsMaxSeed = 100, + Summoned = true, + SummonMaster = caster + }; + follower.Hits = 100; + + ArcaneEmpowermentSpell.ApplyEffectForTests(caster, 1200, 2); + + Assert.True(ArcaneEmpowermentSpell.IsUnderEffects(caster)); + Assert.Equal(110, follower.HitsMax); + Assert.Equal(110, follower.Hits); + + var damage = 100; + follower.AlterMeleeDamageTo(target, ref damage); + Assert.Equal(120, damage); + + var spellDamage = 100; + ArcaneEmpowermentSpell.ApplySpellDamage(caster, target, ref spellDamage); + Assert.Equal(120, spellDamage); + + var healing = 100; + ArcaneEmpowermentSpell.ApplyHealing(caster, ref healing); + Assert.Equal(120, healing); + + Assert.Equal(70.0, ArcaneEmpowermentSpell.GetDispelDifficulty(follower)); + + ArcaneEmpowermentSpell.ApplyEffectForTests(caster, 1200, 0); + + damage = 100; + follower.AlterMeleeDamageTo(target, ref damage); + Assert.Equal(110, damage); + + ArcaneEmpowermentSpell.StopEffect(caster); + + Assert.False(ArcaneEmpowermentSpell.IsUnderEffects(caster)); + Assert.Equal(100, follower.HitsMax); + Assert.Equal(100, follower.Hits); + Assert.Equal(50.0, ArcaneEmpowermentSpell.GetDispelDifficulty(follower)); + + caster.Delete(); + target.Delete(); + follower.Delete(); + } + + [Fact] + public void Logout_CleansUpTemporaryEffect() + { + var caster = NewCaster(); + ArcaneEmpowermentSpell.ApplyEffectForTests(caster, 1200, 1); + + EventSink.InvokeLogout(caster); + + Assert.False(ArcaneEmpowermentSpell.IsUnderEffects(caster)); + caster.Delete(); + } + + [Fact] + public void DeathOrDeleteEvent_CleansUpTemporaryEffect() + { + var caster = NewCaster(); + ArcaneEmpowermentSpell.ApplyEffectForTests(caster, 1200, 1); + + ArcaneEmpowermentSpell.OnMobileRemoved(caster); + + Assert.False(ArcaneEmpowermentSpell.IsUnderEffects(caster)); + caster.Delete(); + } + + [Fact] + public void PlayerVsPlayerDamage_DoesNotExceedTheSdiCap() + { + var caster = NewPlayerCaster(); + var target = NewPlayerCaster(); + var glasses = new WizardsGlasses(); + caster.AddItem(glasses); + + Assert.Equal(15, AosAttributes.GetValue(caster, AosAttribute.SpellDamage)); + + ArcaneEmpowermentSpell.ApplyEffectForTests(caster, 1200, 5); + + var damage = 100; + ArcaneEmpowermentSpell.ApplySpellDamage(caster, target, ref damage); + + Assert.Equal(100, damage); + + ArcaneEmpowermentSpell.StopEffect(caster); + caster.Delete(); + target.Delete(); + glasses.Delete(); + } + + private static Mobile NewCaster(double spellweaving = 120.0) + { + var caster = new Mobile(World.NewMobile); + caster.DefaultMobileInit(); + caster.InitStats(100, 100, 100); + caster.Skills.Spellweaving.Base = spellweaving; + return caster; + } + + private static Mobile NewTarget() + { + var target = new Mobile(World.NewMobile); + target.DefaultMobileInit(); + target.InitStats(200, 100, 100); + target.Hits = 1; + return target; + } + + private static PlayerMobile NewPlayerCaster() + { + var caster = new PlayerMobile(World.NewMobile); + caster.DefaultMobileInit(); + caster.Player = true; + caster.InitStats(100, 100, 100); + caster.Skills.Spellweaving.Base = 120.0; + return caster; + } + + private static int GetSpellScrollId(SpellScroll scroll) + { + var field = typeof(SpellScroll).GetField("_spellID", BindingFlags.Instance | BindingFlags.NonPublic); + return (int)field!.GetValue(scroll)!; + } + + private static void ResetSpellRegistry() + { + var types = (Type[])typeof(SpellRegistry) + .GetField("m_Types", BindingFlags.Static | BindingFlags.NonPublic)! + .GetValue(null)!; + Array.Clear(types); + + var idsFromTypes = (Dictionary)typeof(SpellRegistry) + .GetField("m_IDsFromTypes", BindingFlags.Static | BindingFlags.NonPublic)! + .GetValue(null)!; + idsFromTypes.Clear(); + + typeof(SpellRegistry) + .GetField("m_Count", BindingFlags.Static | BindingFlags.NonPublic)! + .SetValue(null, 0); + + SpellRegistry.SpecialMoves.Clear(); + } + + private sealed class TestSummon : BaseCreature + { + public TestSummon() : base(AIType.AI_Animal, FightMode.Closest, 10, 1) + { + Body = 0xC8; + } + + public override double DispelDifficulty => 50.0; + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.2; + passiveSpeed = 0.4; + } + } +} diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 489574816..db35b3bcb 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -602,7 +602,10 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public override int HitsMax => - HitsMaxSeed <= 0 ? Str : Math.Clamp(HitsMaxSeed + GetStatOffset(StatType.Str), 1, 65000); + ArcaneEmpowermentSpell.GetFollowerHitsMax( + this, + HitsMaxSeed <= 0 ? Str : Math.Clamp(HitsMaxSeed + GetStatOffset(StatType.Str), 1, 65000) + ); [CommandProperty(AccessLevel.GameMaster)] public int HitsMaxSeed { get; set; } = -1; @@ -4177,6 +4180,7 @@ namespace Server.Mobiles public virtual void AlterSpellDamageTo(Mobile to, ref int damage) { TriggerAbilityAlterDamage(MonsterAbilityTrigger.GiveSpellDamage, to, ref damage); + ArcaneEmpowermentSpell.AlterFollowerSpellDamageTo(this, to, ref damage); } public virtual void AlterMeleeDamageFrom(Mobile from, ref int damage) @@ -4187,6 +4191,7 @@ namespace Server.Mobiles public virtual void AlterMeleeDamageTo(Mobile to, ref int damage) { TriggerAbilityAlterDamage(MonsterAbilityTrigger.GiveMeleeDamage, to, ref damage); + ArcaneEmpowermentSpell.AlterFollowerMeleeDamageTo(this, ref damage); } public virtual bool CheckFoodPreference(Item f) => diff --git a/Projects/UOContent/Spells/Base/SpellHelper.cs b/Projects/UOContent/Spells/Base/SpellHelper.cs index 83379c5b4..2873118dd 100644 --- a/Projects/UOContent/Spells/Base/SpellHelper.cs +++ b/Projects/UOContent/Spells/Base/SpellHelper.cs @@ -14,6 +14,7 @@ using Server.Spells.Fifth; using Server.Spells.Necromancy; using Server.Spells.Ninjitsu; using Server.Spells.Seventh; +using Server.Spells.Spellweaving; using Server.Targeting; namespace Server @@ -963,6 +964,8 @@ namespace Server.Spells damageGiven = AOS.Scale(damageGiven, 100 + spellFocusingOffset); } + ArcaneEmpowermentSpell.ApplySpellDamage(from, target, ref damageGiven); + target.Damage(damageGiven, from); bcFrom?.OnDamageSpell(target, damageGiven); @@ -1036,6 +1039,8 @@ namespace Server.Spells dmg = AOS.Scale(dmg, 100 + spellFocusingOffset); } + ArcaneEmpowermentSpell.ApplySpellDamage(from, target, ref dmg); + if (Feint.GetDamageReduction(from, target, out var feintReduction)) { // example: 35 damage * 50 / 100 = 17 damage @@ -1089,7 +1094,7 @@ namespace Server.Spells } public static void Heal(int amount, Mobile target, Mobile from, bool message = true) { - // TODO: All Healing *spells* go through ArcaneEmpowerment + ArcaneEmpowermentSpell.ApplyHealing(from, ref amount); target.Heal(amount, from, message); } @@ -1125,6 +1130,8 @@ namespace Server.Spells m_Damage = AOS.Scale(m_Damage, 100 + spellFocusingOffset); } + ArcaneEmpowermentSpell.ApplySpellDamage(m_From, m_Target, ref m_Damage); + m_Target.Damage(m_Damage); m_Spell?.RemoveDelayedDamageContext(m_Target); } diff --git a/Projects/UOContent/Spells/Chivalry/NobleSacrifice.cs b/Projects/UOContent/Spells/Chivalry/NobleSacrifice.cs index c34207646..1a77746ca 100644 --- a/Projects/UOContent/Spells/Chivalry/NobleSacrifice.cs +++ b/Projects/UOContent/Spells/Chivalry/NobleSacrifice.cs @@ -102,7 +102,7 @@ namespace Server.Spells.Chivalry var toHeal = Math.Clamp(ComputePowerValue(10) + Utility.RandomMinMax(0, 2), 8, 24); Caster.DoBeneficial(m); - m.Heal(toHeal, Caster); + SpellHelper.Heal(toHeal, m, Caster); sendEffect = true; } diff --git a/Projects/UOContent/Spells/Initializer.cs b/Projects/UOContent/Spells/Initializer.cs index 3ff29ab8a..fe495536d 100644 --- a/Projects/UOContent/Spells/Initializer.cs +++ b/Projects/UOContent/Spells/Initializer.cs @@ -175,7 +175,7 @@ namespace Server.Spells Register(612, typeof(EtherealVoyageSpell)); Register(613, typeof(WordOfDeathSpell)); Register(614, typeof(GiftOfLifeSpell)); - // Register(615, typeof(ArcaneEmpowermentSpell)); + Register(615, typeof(ArcaneEmpowermentSpell)); } if (Core.SA) diff --git a/Projects/UOContent/Spells/Seventh/MassDispel.cs b/Projects/UOContent/Spells/Seventh/MassDispel.cs index bd5113e08..57a99655b 100644 --- a/Projects/UOContent/Spells/Seventh/MassDispel.cs +++ b/Projects/UOContent/Spells/Seventh/MassDispel.cs @@ -1,6 +1,7 @@ using Server.Collections; using Server.Items; using Server.Mobiles; +using Server.Spells.Spellweaving; namespace Server.Spells.Seventh { @@ -44,7 +45,8 @@ namespace Server.Spells.Seventh } var dispelChance = - (50.0 + 100 * (Caster.Skills.Magery.Value - bc.DispelDifficulty) / (bc.DispelFocus * 2)) / 100; + (50.0 + 100 * (Caster.Skills.Magery.Value - ArcaneEmpowermentSpell.GetDispelDifficulty(bc)) / + (bc.DispelFocus * 2)) / 100; if (dispelChance > Utility.RandomDouble()) { diff --git a/Projects/UOContent/Spells/Sixth/Dispel.cs b/Projects/UOContent/Spells/Sixth/Dispel.cs index cf3171109..de3bf8fb4 100644 --- a/Projects/UOContent/Spells/Sixth/Dispel.cs +++ b/Projects/UOContent/Spells/Sixth/Dispel.cs @@ -1,6 +1,7 @@ using Server.Items; using Server.Mobiles; using Server.Targeting; +using Server.Spells.Spellweaving; namespace Server.Spells.Sixth { @@ -33,7 +34,8 @@ namespace Server.Spells.Sixth SpellHelper.Turn(Caster, m); var dispelChance = - (50.0 + 100 * (Caster.Skills.Magery.Value - bc.DispelDifficulty) / (bc.DispelFocus * 2)) / 100; + (50.0 + 100 * (Caster.Skills.Magery.Value - ArcaneEmpowermentSpell.GetDispelDifficulty(bc)) / + (bc.DispelFocus * 2)) / 100; if (dispelChance > Utility.RandomDouble()) { diff --git a/Projects/UOContent/Spells/Spellweaving/ArcaneEmpowerment.cs b/Projects/UOContent/Spells/Spellweaving/ArcaneEmpowerment.cs new file mode 100644 index 000000000..48f838e51 --- /dev/null +++ b/Projects/UOContent/Spells/Spellweaving/ArcaneEmpowerment.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; +using ModernUO.CodeGeneratedEvents; +using Server.Engines.BuffIcons; +using Server.Items; +using Server.Mobiles; + +namespace Server.Spells.Spellweaving +{ + public class ArcaneEmpowermentSpell : ArcanistSpell + { + private const int SummonHealthBonus = 110; + private const int DispelDifficultyPerFocus = 10; + private const int PvPSdiCap = 15; + + private static readonly SpellInfo _info = new("Arcane Empowerment", "Aslavdra", -1); + private static readonly Dictionary _table = new(); + + static ArcaneEmpowermentSpell() => EventSink.Logout += OnLogout; + + public ArcaneEmpowermentSpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info) + { + } + + public override TimeSpan CastDelayBase => TimeSpan.FromSeconds(4.0); + public override double RequiredSkill => 24.0; + public override int RequiredMana => 50; + + internal static TimeSpan GetDuration(int spellweavingFixed, int focusLevel) => + TimeSpan.FromSeconds(15 + Math.Max(1, spellweavingFixed / 240) + 2 * focusLevel); + + internal static int GetBaseBonus(int spellweavingFixed) => Math.Max(0, spellweavingFixed / 120); + + internal static int GetSpellDamageBonus(int spellweavingFixed, int focusLevel, bool playerTarget) => + GetBaseBonus(spellweavingFixed) + focusLevel * (playerTarget ? 1 : 5); + + internal static int GetHealingBonus(int spellweavingFixed, int focusLevel) => + GetBaseBonus(spellweavingFixed) + focusLevel * 5; + + internal static int GetDispelDifficultyBonus(int focusLevel) => focusLevel * DispelDifficultyPerFocus; + + internal static bool IsUnderEffects(Mobile caster) => caster != null && _table.ContainsKey(caster); + + public override void OnCast() + { + if (CheckSequence()) + { + ApplyEffect(Caster, Caster.Skills.Spellweaving.Fixed, FocusLevel); + Caster.PlaySound(0x5C1); + } + + FinishSequence(); + } + + internal static void ApplyEffectForTests(Mobile caster, int spellweavingFixed, int focusLevel) + { + ApplyEffect(caster, spellweavingFixed, focusLevel); + } + + internal static void StopEffect(Mobile caster) + { + if (caster == null || !_table.Remove(caster, out var context)) + { + return; + } + + context.TimerToken.Cancel(); + + foreach (var follower in context.Followers) + { + if (follower?.Deleted == false && follower.Hits > follower.HitsMax) + { + follower.Hits = follower.HitsMax; + } + } + + (caster as PlayerMobile)?.RemoveBuff(BuffIcon.ArcaneEmpowerment); + + if (caster.Deleted == false && caster.Alive) + { + caster.PlaySound(0x5C2); + } + } + + internal static void ApplySpellDamage(Mobile caster, Mobile target, ref int damage) + { + if (damage <= 0 || caster?.Alive != true || target?.Deleted != false || + !_table.TryGetValue(caster, out var context)) + { + return; + } + + var playerTarget = caster.Player && target.Player; + var bonus = GetSpellDamageBonus(context.SpellweavingFixed, context.FocusLevel, playerTarget); + + if (playerTarget) + { + // GetNewAosDamage already includes item SDI and caps it at 15% in PvP. Arcane + // Empowerment may use only the remaining room so the combined value stays capped. + var itemSdi = AosAttributes.GetValue(caster, AosAttribute.SpellDamage); + bonus = Math.Min(bonus, Math.Max(0, PvPSdiCap - itemSdi)); + } + + damage = AOS.Scale(damage, 100 + bonus); + } + + internal static void ApplyHealing(Mobile caster, ref int amount) + { + if (amount <= 0 || caster?.Alive != true || !_table.TryGetValue(caster, out var context)) + { + return; + } + + amount = AOS.Scale(amount, 100 + GetHealingBonus(context.SpellweavingFixed, context.FocusLevel)); + } + + internal static int GetFollowerHitsMax(BaseCreature follower, int baseHitsMax) + { + if (!TryGetFollowerContext(follower, out var context)) + { + return baseHitsMax; + } + + if (context.Followers.Add(follower) && follower.Hits >= baseHitsMax) + { + follower.Hits = AOS.Scale(baseHitsMax, SummonHealthBonus); + } + + return AOS.Scale(baseHitsMax, SummonHealthBonus); + } + + internal static void AlterFollowerMeleeDamageTo(BaseCreature follower, ref int damage) + { + AlterFollowerDamageTo(follower, null, ref damage); + } + + internal static void AlterFollowerSpellDamageTo(BaseCreature follower, Mobile target, ref int damage) + { + AlterFollowerDamageTo(follower, target, ref damage); + } + + internal static double GetDispelDifficulty(BaseCreature follower) + { + return TryGetFollowerContext(follower, out var context) + ? follower.DispelDifficulty + GetDispelDifficultyBonus(context.FocusLevel) + : follower.DispelDifficulty; + } + + [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] + [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] + [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] + [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] + public static void OnMobileRemoved(Mobile mobile) + { + StopEffect(mobile); + + if (mobile is BaseCreature follower) + { + foreach (var context in _table.Values) + { + context.Followers.Remove(follower); + } + } + } + + private static void OnLogout(Mobile mobile) => StopEffect(mobile); + + private static void ApplyEffect(Mobile caster, int spellweavingFixed, int focusLevel) + { + StopEffect(caster); + + var duration = GetDuration(spellweavingFixed, focusLevel); + var context = new EmpowermentContext(spellweavingFixed, focusLevel); + _table[caster] = context; + + TrackNearbyFollowers(caster); + + if (caster is PlayerMobile player) + { + var bonus = GetSpellDamageBonus(spellweavingFixed, focusLevel, false); + player.AddBuff( + new BuffInfo( + BuffIcon.ArcaneEmpowerment, + 1031616, + 1075808, + duration, + $"{bonus}\t10" + ) + ); + } + + Timer.StartTimer(duration, () => StopEffect(caster), out context.TimerToken); + } + + private static bool TryGetFollowerContext(BaseCreature follower, out EmpowermentContext context) + { + context = null; + + if (follower?.Deleted != false || !follower.Summoned && !follower.IsAnimatedDead) + { + return false; + } + + var master = follower.GetMaster(); + + return master?.Alive == true && _table.TryGetValue(master, out context); + } + + private static void AlterFollowerDamageTo(BaseCreature follower, Mobile target, ref int damage) + { + if (!TryGetFollowerContext(follower, out var context)) + { + return; + } + + damage = AOS.Scale( + damage, + 100 + GetSpellDamageBonus(context.SpellweavingFixed, context.FocusLevel, target?.Player == true) + ); + } + + private static void TrackNearbyFollowers(Mobile caster) + { + if (caster.Map == null || caster.Map == Map.Internal) + { + return; + } + + foreach (var follower in caster.GetMobilesInRange(18)) + { + if ((follower.Summoned || follower.IsAnimatedDead) && follower.GetMaster() == caster) + { + GetFollowerHitsMax(follower, GetUnmodifiedHitsMax(follower)); + } + } + } + + private static int GetUnmodifiedHitsMax(BaseCreature follower) => + follower.HitsMaxSeed <= 0 + ? follower.Str + : Math.Clamp(follower.HitsMaxSeed + follower.GetStatOffset(StatType.Str), 1, 65000); + + private sealed class EmpowermentContext + { + public EmpowermentContext(int spellweavingFixed, int focusLevel) + { + SpellweavingFixed = spellweavingFixed; + FocusLevel = focusLevel; + } + + public int SpellweavingFixed { get; } + public int FocusLevel { get; } + public TimerExecutionToken TimerToken; + public HashSet Followers { get; } = new(); + } + } +} \ No newline at end of file