diff --git a/Projects/UOContent.Tests/Tests/Spells/Mysticism/EnchantSpellTests.cs b/Projects/UOContent.Tests/Tests/Spells/Mysticism/EnchantSpellTests.cs new file mode 100644 index 000000000..5c4582e26 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Spells/Mysticism/EnchantSpellTests.cs @@ -0,0 +1,548 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Reflection; +using Server.Gumps; +using Server.Items; +using Server.Mobiles; +using Server.Network; +using Server.Spells; +using Server.Spells.Mysticism; +using Server.Spells.Ninjitsu; +using Server.Tests; +using Xunit; + +namespace Server.Tests.Spells.Mysticism; + +[Collection("Sequential UOContent Tests")] +public class EnchantSpellTests +{ + [Fact] + public void SpellMetadata_MatchesEnchantSources() + { + var caster = NewCaster(); + var spell = new EnchantSpell(caster); + + Assert.Equal("Enchant", spell.Name); + Assert.Equal("In Ort Ylem", spell.Mantra); + Assert.Equal(SpellCircle.Second, spell.Circle); + Assert.Equal(TimeSpan.FromSeconds(0.75), spell.CastDelayBase); + Assert.Equal(6, spell.GetMana()); + Assert.Equal(8.0, spell.RequiredSkill); + Assert.Equal(SkillName.Mysticism, spell.CastSkill); + Assert.Equal([Reagent.SpidersSilk, Reagent.MandrakeRoot, Reagent.SulfurousAsh], spell.Reagents); + Assert.False(spell.ClearHandsOnCast); + + caster.Delete(); + } + + [Fact] + public void MysticSpellbookAndScroll_UseEnchantSlot() + { + var book = new MysticSpellbook(1UL << (680 - 677)); + var scroll = new EnchantScroll(); + + Assert.Equal(677, book.BookOffset); + Assert.Equal(16, book.BookCount); + Assert.True(book.HasSpell(680)); + Assert.Equal(680, GetSpellScrollId(scroll)); + + book.Delete(); + scroll.Delete(); + } + + [Fact] + public void RegisterMysticism_PreSaDoesNotExposeEnchant_AndSaUsesSpellId680() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.ML; + Initializer.Configure(); + + Assert.Null(SpellRegistry.NewSpell(680, caster, null)); + Assert.Equal(-1, SpellRegistry.GetRegistryNumber(typeof(EnchantSpell))); + + ResetSpellRegistry(); + Core.Expansion = Expansion.SA; + Initializer.Configure(); + + Assert.Same(typeof(EnchantSpell), SpellRegistry.Types[680]); + Assert.Equal(680, SpellRegistry.GetRegistryNumber(typeof(EnchantSpell))); + Assert.IsType(SpellRegistry.NewSpell(680, caster, null)); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void Duration_ScalesWithSelectedHitSpellLevelAndCapsAt150Seconds() + { + Assert.Equal(TimeSpan.FromSeconds(30), EnchantSpell.GetDuration(AosWeaponAttribute.HitMagicArrow)); + Assert.Equal(TimeSpan.FromSeconds(60), EnchantSpell.GetDuration(AosWeaponAttribute.HitHarm)); + Assert.Equal(TimeSpan.FromSeconds(90), EnchantSpell.GetDuration(AosWeaponAttribute.HitFireball)); + Assert.Equal(TimeSpan.FromSeconds(120), EnchantSpell.GetDuration(AosWeaponAttribute.HitLightning)); + Assert.Equal(TimeSpan.FromSeconds(150), EnchantSpell.GetDuration(AosWeaponAttribute.HitDispel)); + } + + [Fact] + public void HitSpellChance_UsesHigherFocusOrImbuingAndCapsAt60() + { + var focusCaster = NewCaster(120, 120, 0); + var imbuingCaster = NewCaster(120, 0, 120); + var lowCaster = NewCaster(80, 0, 0); + + Assert.Equal(60, EnchantSpell.GetHitSpellChance(focusCaster)); + Assert.Equal(60, EnchantSpell.GetHitSpellChance(imbuingCaster)); + var lowExpected = (int)(60 * (lowCaster.Skills.Mysticism.Value + + Math.Max(lowCaster.Skills.Focus.Value, lowCaster.Skills.Imbuing.Value)) / 240.0); + Assert.Equal(lowExpected, EnchantSpell.GetHitSpellChance(lowCaster)); + + focusCaster.Delete(); + imbuingCaster.Delete(); + lowCaster.Delete(); + } + + [Fact] + public void Selection_AppliesRuntimeOnlyHitSpellAndChannelingState() + { + var previousExpansion = Core.Expansion; + Core.Expansion = Expansion.SA; + + var caster = NewCaster(); + var weapon = new Katana(); + AddReagents(caster); + Assert.True(caster.EquipItem(weapon)); + + try + { + var spell = BeginSelection(caster); + var baselineCastDelay = new NetherBoltSpell(caster).GetCastDelay(); + spell.FinishSelection(weapon, AosWeaponAttribute.HitLightning); + + Assert.Null(caster.Spell); + Assert.Equal(SpellState.None, spell.State); + Assert.True(EnchantSpell.IsEnchanted(weapon)); + Assert.Equal(60, EnchantSpell.GetHitSpellBonus(weapon, AosWeaponAttribute.HitLightning)); + Assert.Equal(0, weapon.WeaponAttributes.HitLightning); + Assert.Equal(0, weapon.Attributes.SpellChanneling); + Assert.True(EnchantSpell.ProvidesSpellChanneling(weapon, caster)); + Assert.Equal(-1, EnchantSpell.GetFasterCasting(caster)); + Assert.Equal(TimeSpan.FromSeconds(0.25), new NetherBoltSpell(caster).GetCastDelay() - baselineCastDelay); + Assert.True(weapon.AllowEquippedCast(caster)); + Assert.Equal(94, caster.Mana); + Assert.Equal(9, caster.Backpack.FindItemByType().Amount); + Assert.Equal(9, caster.Backpack.FindItemByType().Amount); + Assert.Equal(9, caster.Backpack.FindItemByType().Amount); + + caster.RemoveItem(weapon); + + Assert.False(EnchantSpell.IsEnchanted(weapon)); + Assert.Equal(0, EnchantSpell.GetHitSpellBonus(weapon, AosWeaponAttribute.HitLightning)); + Assert.Equal(0, EnchantSpell.GetFasterCasting(caster)); + + weapon.Attributes.SpellChanneling = 1; + AddReagents(caster); + Assert.True(caster.EquipItem(weapon)); + var permanentChannelingSpell = BeginSelection(caster); + permanentChannelingSpell.FinishSelection(weapon, AosWeaponAttribute.HitHarm); + + Assert.False(EnchantSpell.ProvidesSpellChanneling(weapon, caster)); + Assert.Equal(-1, EnchantSpell.GetFasterCasting(caster)); + } + finally + { + EnchantSpell.StopEffect(weapon); + weapon.Delete(); + caster.Delete(); + Core.Expansion = previousExpansion; + } + } + + [Fact] + public void Selection_BelowThresholdDoesNotGrantTemporaryChannelingOrFasterCasting() + { + var previousExpansion = Core.Expansion; + Core.Expansion = Expansion.SA; + + var caster = NewCaster(79, 120, 0); + var weapon = new Katana(); + AddReagents(caster); + Assert.True(caster.EquipItem(weapon)); + + try + { + var spell = BeginSelection(caster); + spell.FinishSelection(weapon, AosWeaponAttribute.HitMagicArrow); + + Assert.True(EnchantSpell.IsEnchanted(weapon)); + Assert.False(EnchantSpell.ProvidesSpellChanneling(weapon, caster)); + Assert.Equal(0, EnchantSpell.GetFasterCasting(caster)); + var expected = (int)(60 * (caster.Skills.Mysticism.Value + + Math.Max(caster.Skills.Focus.Value, caster.Skills.Imbuing.Value)) / 240.0); + Assert.Equal(expected, EnchantSpell.GetHitSpellBonus(weapon, AosWeaponAttribute.HitMagicArrow)); + } + finally + { + EnchantSpell.StopEffect(weapon); + weapon.Delete(); + caster.Delete(); + Core.Expansion = previousExpansion; + } + } + + [Fact] + public void Threshold_At80MysticismAndSupportSkillGrantsAdvancedEffects() + { + var previousExpansion = Core.Expansion; + Core.Expansion = Expansion.SA; + + var caster = NewCaster(80, 80, 0); + var weapon = new Katana(); + AddReagents(caster); + + try + { + Assert.True(caster.EquipItem(weapon)); + var spell = BeginSelection(caster); + spell.FinishSelection(weapon, AosWeaponAttribute.HitMagicArrow); + + Assert.True(EnchantSpell.ProvidesSpellChanneling(weapon, caster)); + Assert.Equal(-1, EnchantSpell.GetFasterCasting(caster)); + } + finally + { + EnchantSpell.StopEffect(weapon); + weapon.Delete(); + caster.Delete(); + Core.Expansion = previousExpansion; + } + } + + [Fact] + public void CheckCast_RejectsExistingHitSpellAndIncompatibleEnchantments() + { + var previousExpansion = Core.Expansion; + Core.Expansion = Expansion.SA; + + var caster = NewCaster(); + var weapon = new Katana(); + Assert.True(caster.EquipItem(weapon)); + + try + { + var spell = new EnchantSpell(caster); + weapon.WeaponAttributes.HitFireball = 1; + Assert.False(spell.CheckCast()); + + weapon.WeaponAttributes.HitFireball = 0; + weapon.Consecrated = true; + Assert.False(spell.CheckCast()); + + weapon.Consecrated = false; + caster.Skills.Ninjitsu.Base = 120.0; + SpecialMove.Table[caster] = new FocusAttack(); + Assert.False(spell.CheckCast()); + SpecialMove.Table.Remove(caster); + } + finally + { + SpecialMove.Table.Remove(caster); + weapon.Delete(); + caster.Delete(); + Core.Expansion = previousExpansion; + } + } + + [Fact] + public void Expiry_RemovesTemporaryStateAndCasterLifecycleBreaksEffect() + { + var previousExpansion = Core.Expansion; + Core.Expansion = Expansion.SA; + + var caster = NewCaster(); + var weapon = new Katana(); + AddReagents(caster); + Assert.True(caster.EquipItem(weapon)); + + try + { + var spell = BeginSelection(caster); + spell.FinishSelection(weapon, AosWeaponAttribute.HitDispel); + Assert.True(EnchantSpell.IsEnchanted(weapon)); + + EnchantSpell.ExpireForTests(weapon); + Assert.False(EnchantSpell.IsEnchanted(weapon)); + Assert.Equal(0, EnchantSpell.GetHitSpellBonus(weapon, AosWeaponAttribute.HitDispel)); + Assert.Equal(0, EnchantSpell.GetFasterCasting(caster)); + + spell = BeginSelection(caster); + spell.FinishSelection(weapon, AosWeaponAttribute.HitHarm); + Assert.True(EnchantSpell.IsEnchanted(weapon)); + + caster.RemoveItem(weapon); + Assert.False(EnchantSpell.IsEnchanted(weapon)); + + Assert.True(caster.EquipItem(weapon)); + spell = BeginSelection(caster); + spell.FinishSelection(weapon, AosWeaponAttribute.HitHarm); + Assert.True(EnchantSpell.IsEnchanted(weapon)); + + EnchantSpell.Configure(); + EventSink.InvokeLogout(caster); + Assert.False(EnchantSpell.IsEnchanted(weapon)); + + spell = BeginSelection(caster); + spell.FinishSelection(weapon, AosWeaponAttribute.HitHarm); + Assert.True(EnchantSpell.IsEnchanted(weapon)); + + PlayerMobile.PlayerDeletedEvent(caster); + Assert.False(EnchantSpell.IsEnchanted(weapon)); + Assert.Equal(0, EnchantSpell.GetHitSpellBonus(weapon, AosWeaponAttribute.HitHarm)); + } + finally + { + EnchantSpell.StopEffect(weapon); + weapon.Delete(); + caster.Delete(); + Core.Expansion = previousExpansion; + } + } + + [Fact] + public void OnHit_UsesTemporaryHitSpellBonus() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + Core.Expansion = Expansion.SA; + + var caster = NewCaster(); + caster.MoveToWorld(new Point3D(6200, 520, 0), Map.Felucca); + var weapon = new TestEnchantKatana(); + var baselineDefender = NewCombatMobile(new Point3D(6201, 520, 0)); + var enchantedDefender = NewCombatMobile(new Point3D(6201, 520, 0)); + AddReagents(caster); + + try + { + Assert.True(caster.EquipItem(weapon)); + var baselineHits = baselineDefender.Hits; + weapon.OnHit(caster, baselineDefender); + var baselineDamage = baselineHits - baselineDefender.Hits; + + var spell = BeginSelection(caster); + spell.FinishSelection(weapon, AosWeaponAttribute.HitMagicArrow); + + weapon.OnHit(caster, enchantedDefender); + + Assert.Equal(1, baselineDamage); + Assert.Equal(1, weapon.MagicArrowCount); + } + finally + { + EnchantSpell.StopEffect(weapon); + weapon.Delete(); + baselineDefender.Delete(); + enchantedDefender.Delete(); + caster.Delete(); + Core.Expansion = previousExpansion; + } + } + + [Fact] + public void SelectionGumpResponse_AppliesSelectedEffect() + { + var previousExpansion = Core.Expansion; + Core.Expansion = Expansion.SA; + + var caster = NewCaster(); + var weapon = new Katana(); + AddReagents(caster); + + try + { + Assert.True(caster.EquipItem(weapon)); + var spell = BeginSelection(caster); + var gump = new EnchantGump(spell, weapon); + var relay = new RelayInfo( + 1, + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + ReadOnlySpan.Empty + ); + + gump.OnResponse(null, in relay); + + Assert.True(EnchantSpell.IsEnchanted(weapon)); + Assert.Equal(60, EnchantSpell.GetHitSpellBonus(weapon, AosWeaponAttribute.HitLightning)); + } + finally + { + EnchantSpell.StopEffect(weapon); + weapon.Delete(); + caster.Delete(); + Core.Expansion = previousExpansion; + } + } + + [Fact] + public void SelectionCancel_DoesNotConsumeManaOrReagents() + { + var previousExpansion = Core.Expansion; + Core.Expansion = Expansion.SA; + + var caster = NewCaster(); + var weapon = new Katana(); + AddReagents(caster); + + try + { + Assert.True(caster.EquipItem(weapon)); + var spell = BeginSelection(caster); + var gump = new EnchantGump(spell, weapon); + var relay = new RelayInfo( + 0, + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + ReadOnlySpan.Empty + ); + + gump.OnResponse(null, in relay); + + Assert.Null(caster.Spell); + Assert.False(EnchantSpell.IsEnchanted(weapon)); + Assert.Equal(100, caster.Mana); + Assert.Equal(10, caster.Backpack.FindItemByType().Amount); + Assert.Equal(10, caster.Backpack.FindItemByType().Amount); + Assert.Equal(10, caster.Backpack.FindItemByType().Amount); + } + finally + { + EnchantSpell.StopEffect(weapon); + weapon.Delete(); + caster.Delete(); + Core.Expansion = previousExpansion; + } + } + + [Fact] + public void SelectionGump_CompilesWithVisibleLayout() + { + var caster = NewCaster(); + var weapon = new Katana(); + var spell = new EnchantSpell(caster); + var gump = new EnchantGump(spell, weapon); + var buffer = GC.AllocateUninitializedArray(2048); + var writer = new SpanWriter(buffer); + gump.Compile(ref writer); + + Assert.True(writer.BytesWritten > 0); + + weapon.Delete(); + caster.Delete(); + } + + private static PlayerMobile NewCaster(double mysticism = 120.0, double focus = 120.0, double imbuing = 0.0) + { + var caster = new PlayerMobile(World.NewMobile); + caster.DefaultMobileInit(); + caster.Player = true; + caster.InitStats(100, 100, 100); + caster.Mana = caster.ManaMax; + caster.AddItem(new Backpack()); + caster.Skills.Mysticism.Base = mysticism; + caster.Skills.Focus.Base = focus; + caster.Skills.Imbuing.Base = imbuing; + return caster; + } + + private static Mobile NewCombatMobile(Point3D location) + { + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.InitStats(100, 100, 100); + mobile.Hits = mobile.HitsMax; + mobile.MoveToWorld(location, Map.Felucca); + return mobile; + } + + private static TestEnchantSpell BeginSelection(PlayerMobile caster) + { + var spell = new TestEnchantSpell(caster); + caster.Spell = spell; + spell.State = SpellState.Sequencing; + return spell; + } + + private static void AddReagents(Mobile caster) + { + caster.AddToBackpack(new SpidersSilk(10)); + caster.AddToBackpack(new MandrakeRoot(10)); + caster.AddToBackpack(new SulfurousAsh(10)); + } + + 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 TestEnchantKatana : Katana + { + public int MagicArrowCount { get; private set; } + + public override bool CheckHit(Mobile attacker, Mobile defender) => true; + + public override int ComputeDamage(Mobile attacker, Mobile defender) => 1; + + public override void DoMagicArrow(Mobile attacker, Mobile defender) + { + MagicArrowCount++; + base.DoMagicArrow(attacker, defender); + } + + public override int AbsorbDamage(Mobile attacker, Mobile defender, int damage) => damage; + + public override void AddBlood(Mobile attacker, Mobile defender, int damage) + { + } + } + + private sealed class TestEnchantSpell : EnchantSpell + { + public TestEnchantSpell(Mobile caster) : base(caster) + { + } + + public override bool CheckFizzle() => true; + } +} diff --git a/Projects/UOContent/Gumps/EnchantGump.cs b/Projects/UOContent/Gumps/EnchantGump.cs new file mode 100644 index 000000000..41c10ad18 --- /dev/null +++ b/Projects/UOContent/Gumps/EnchantGump.cs @@ -0,0 +1,90 @@ +using Server.Network; +using Server.Spells; +using Server.Spells.Mysticism; +using Server.Items; + +namespace Server.Gumps; + +public sealed class EnchantGump : StaticGump +{ + private readonly EnchantSpell _spell; + private readonly BaseWeapon _weapon; + + public EnchantGump(EnchantSpell spell, BaseWeapon weapon) : base(20, 20) + { + _spell = spell; + _weapon = weapon; + } + + public override bool Singleton => true; + + public static void DisplayTo(Mobile from, EnchantSpell spell, BaseWeapon weapon) + { + if (from == null || from.Deleted || from.NetState == null || spell == null || spell.Caster != from || + from.Spell != spell || spell.State != SpellState.Sequencing || weapon == null || weapon.Deleted || + weapon.Parent != from) + { + return; + } + + from.CloseGump(); + from.SendGump(new EnchantGump(spell, weapon)); + } + + protected override void BuildLayout(ref StaticGumpBuilder builder) + { + const int font = 0x07FF; + + builder.AddPage(); + builder.AddBackground(0, 0, 260, 187, 3600); + builder.AddAlphaRegion(5, 15, 242, 170); + builder.AddImageTiled(220, 15, 30, 162, 10464); + + builder.AddItem(0, 3, 6882); + builder.AddItem(-8, 170, 6880); + builder.AddItem(185, 3, 6883); + builder.AddItem(192, 170, 6881); + + builder.AddHtmlLocalized(20, 22, 150, 16, 1080133, font, false, false); // Select Enchant + + AddOption(ref builder, 20, 50, 1, 1079705); // Hit Lightning + AddOption(ref builder, 20, 75, 2, 1079703); // Hit Fireball + AddOption(ref builder, 20, 100, 3, 1079704); // Hit Harm + AddOption(ref builder, 20, 125, 4, 1079706); // Hit Magic Arrow + AddOption(ref builder, 20, 150, 5, 1079702); // Hit Dispel + } + + public override void OnResponse(NetState sender, in RelayInfo info) + { + var attribute = info.ButtonID switch + { + 1 => AosWeaponAttribute.HitLightning, + 2 => AosWeaponAttribute.HitFireball, + 3 => AosWeaponAttribute.HitHarm, + 4 => AosWeaponAttribute.HitMagicArrow, + 5 => AosWeaponAttribute.HitDispel, + _ => (AosWeaponAttribute?)null + }; + + if (attribute.HasValue) + { + _spell.FinishSelection(_weapon, attribute.Value); + } + else + { + _spell.CancelSelection(); + } + } + + private static void AddOption( + ref StaticGumpBuilder builder, + int x, + int y, + int buttonId, + int cliloc + ) + { + builder.AddButton(x, y, 9702, 9703, buttonId); + builder.AddHtmlLocalized(x + 25, y, 200, 16, cliloc, 0x07FF, false, false); + } +} diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index 15c7d1884..fa8475ffa 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -15,6 +15,7 @@ using Server.Spells.Bushido; using Server.Spells.Chivalry; using Server.Spells.Necromancy; using Server.Spells.Ninjitsu; +using Server.Spells.Mysticism; using Server.Spells.Sixth; using Server.Spells.Spellweaving; using Server.Text; @@ -1187,6 +1188,7 @@ public abstract partial class BaseWeapon public override void OnRemoved(IEntity parent) { ClearLastParryChance(); + EnchantSpell.StopEffect(this); if (parent is not Mobile m) { @@ -1234,9 +1236,16 @@ public abstract partial class BaseWeapon m.Delta(MobileDelta.WeaponDamage); } + public override void OnAfterDelete() + { + EnchantSpell.StopEffect(this); + base.OnAfterDelete(); + } + public override void OnMapChange() { base.OnMapChange(); + EnchantSpell.StopEffect(this); if ((Map == null || Map == Map.Internal) && Parent is Mobile m && ExtendedWeaponAttributes.BattleLust != 0) { @@ -2291,15 +2300,20 @@ public abstract partial class BaseWeapon } var maChance = - (int)(AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitMagicArrow) * propertyBonus); + (int)((AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitMagicArrow) + + EnchantSpell.GetHitSpellBonus(this, AosWeaponAttribute.HitMagicArrow)) * propertyBonus); var harmChance = - (int)(AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitHarm) * propertyBonus); + (int)((AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitHarm) + + EnchantSpell.GetHitSpellBonus(this, AosWeaponAttribute.HitHarm)) * propertyBonus); var fireballChance = - (int)(AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitFireball) * propertyBonus); + (int)((AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitFireball) + + EnchantSpell.GetHitSpellBonus(this, AosWeaponAttribute.HitFireball)) * propertyBonus); var lightningChance = - (int)(AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitLightning) * propertyBonus); + (int)((AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitLightning) + + EnchantSpell.GetHitSpellBonus(this, AosWeaponAttribute.HitLightning)) * propertyBonus); var dispelChance = - (int)(AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitDispel) * propertyBonus); + (int)((AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitDispel) + + EnchantSpell.GetHitSpellBonus(this, AosWeaponAttribute.HitDispel)) * propertyBonus); if (maChance != 0 && maChance > Utility.Random(100)) { @@ -3063,7 +3077,7 @@ public abstract partial class BaseWeapon } public override bool AllowEquippedCast(Mobile from) => - base.AllowEquippedCast(from) || Attributes.SpellChanneling != 0; + base.AllowEquippedCast(from) || Attributes.SpellChanneling != 0 || EnchantSpell.ProvidesSpellChanneling(this, from); public virtual int GetLuckBonus() => CraftResources.GetInfo(_resource)?.AttributeInfo?.WeaponLuck ?? 0; diff --git a/Projects/UOContent/Spells/Base/Spell.cs b/Projects/UOContent/Spells/Base/Spell.cs index 80a700c93..5ab288ec5 100644 --- a/Projects/UOContent/Spells/Base/Spell.cs +++ b/Projects/UOContent/Spells/Base/Spell.cs @@ -736,6 +736,8 @@ namespace Server.Spells fc -= EssenceOfWindSpell.GetFCMalus(Caster); } + fc += Mysticism.EnchantSpell.GetFasterCasting(Caster); + if (Core.SA) { // At some point OSI added 0.25s to every spell. This makes the minimum 0.5s diff --git a/Projects/UOContent/Spells/Initializer.cs b/Projects/UOContent/Spells/Initializer.cs index a50ce6ef5..49eae3272 100644 --- a/Projects/UOContent/Spells/Initializer.cs +++ b/Projects/UOContent/Spells/Initializer.cs @@ -184,7 +184,7 @@ namespace Server.Spells Register(677, typeof(NetherBoltSpell)); Register(678, typeof(HealingStoneSpell)); Register(679, typeof(PurgeMagicSpell)); - // Register(680, typeof(EnchantSpell)); + Register(680, typeof(EnchantSpell)); // Register(681, typeof(SleepSpell)); Register(682, typeof(EagleStrikeSpell)); Register(683, typeof(AnimatedWeaponSpell)); diff --git a/Projects/UOContent/Spells/Mysticism/EnchantSpell.cs b/Projects/UOContent/Spells/Mysticism/EnchantSpell.cs new file mode 100644 index 000000000..82b1de7dc --- /dev/null +++ b/Projects/UOContent/Spells/Mysticism/EnchantSpell.cs @@ -0,0 +1,386 @@ +using System; +using System.Collections.Generic; +using ModernUO.CodeGeneratedEvents; +using Server.Engines.BuffIcons; +using Server.Gumps; +using Server.Items; +using Server.Mobiles; +using Server.Spells.Ninjitsu; +using Server.Spells.Spellweaving; + +namespace Server.Spells.Mysticism; + +public class EnchantSpell : MysticSpell +{ + private const int MaxHitSpellChance = 60; + private const double SpellChannelingSkillThreshold = 80.0; + + private static readonly SpellInfo _info = new( + "Enchant", + "In Ort Ylem", + 230, + 9022, + Reagent.SpidersSilk, + Reagent.MandrakeRoot, + Reagent.SulfurousAsh + ); + + private static readonly AosWeaponAttribute[] _hitSpellAttributes = + [ + AosWeaponAttribute.HitMagicArrow, + AosWeaponAttribute.HitHarm, + AosWeaponAttribute.HitFireball, + AosWeaponAttribute.HitLightning, + AosWeaponAttribute.HitDispel + ]; + + private static readonly Dictionary _table = new(); + private static bool _configured; + + private TimerExecutionToken _selectionTimer; + + public EnchantSpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info) + { + } + + public override SpellCircle Circle => SpellCircle.Second; + + public static void Configure() + { + if (_configured) + { + return; + } + + _configured = true; + EventSink.Logout += OnLogout; + } + + public override bool ClearHandsOnCast => false; + + public override bool CheckCast() + { + if (!base.CheckCast()) + { + return false; + } + + if (Caster.Weapon is not BaseWeapon weapon || weapon is Fists) + { + Caster.SendLocalizedMessage(501078); // You must be holding a weapon. + return false; + } + + return ValidateWeapon(weapon); + } + + public override void OnCast() + { + if (Caster.Weapon is not BaseWeapon weapon || weapon is Fists) + { + Caster.SendLocalizedMessage(501078); // You must be holding a weapon. + FinishSequence(); + } + else if (!ValidateWeapon(weapon)) + { + FinishSequence(); + } + else + { + EnchantGump.DisplayTo(Caster, this, weapon); + Timer.StartTimer(TimeSpan.FromSeconds(30), CancelSelection, out _selectionTimer); + } + } + + public override void OnDisturb(DisturbType type, bool message) + { + Caster.CloseGump(); + _selectionTimer.Cancel(); + base.OnDisturb(type, message); + } + + public override void FinishSequence() + { + _selectionTimer.Cancel(); + Caster.CloseGump(); + base.FinishSequence(); + } + + internal void FinishSelection(BaseWeapon weapon, AosWeaponAttribute attribute) + { + if (Caster.Spell != this || State != SpellState.Sequencing) + { + return; + } + + try + { + if (Caster.Weapon != weapon || weapon.Deleted || weapon is Fists) + { + Caster.SendLocalizedMessage(501078); // You must be holding a weapon. + return; + } + + if (!ValidateWeapon(weapon)) + { + return; + } + + if (!CheckSequence()) + { + return; + } + + var value = GetHitSpellChance(Caster); + var duration = GetDuration(attribute); + var grantsAdvancedEffects = + Caster.Skills.Mysticism.Value >= SpellChannelingSkillThreshold && + Math.Max(Caster.Skills.Imbuing.Value, Caster.Skills.Focus.Value) >= SpellChannelingSkillThreshold; + var grantsSpellChanneling = grantsAdvancedEffects && weapon.Attributes.SpellChanneling == 0; + + var timer = new EnchantmentTimer( + Caster, + weapon, + attribute, + value, + grantsSpellChanneling, + grantsAdvancedEffects, + duration + ); + _table[weapon] = timer; + timer.Start(); + + Caster.PlaySound(0x64E); + Caster.FixedEffect(0x36CB, 1, 9, 1915, 0); + weapon.InvalidateProperties(); + + if (Caster is PlayerMobile player) + { + player.AddBuff( + new BuffInfo( + BuffIcon.Enchant, + 1080126, + GetBuffCliloc(attribute), + duration, + $"{Caster.Name}\t{value}" + ) + ); + } + } + finally + { + FinishSequence(); + } + } + + internal void CancelSelection() + { + if (Caster.Spell == this && State == SpellState.Sequencing) + { + Caster.SendLocalizedMessage(1080132); // You decide not to enchant your weapon. + FinishSequence(); + } + else + { + _selectionTimer.Cancel(); + Caster.CloseGump(); + } + } + + internal static int GetHitSpellChance(Mobile caster) + { + var skillTotal = GetBaseSkill(caster) + Math.Max(caster.Skills.Imbuing.Value, caster.Skills.Focus.Value); + return Math.Clamp((int)(MaxHitSpellChance * skillTotal / 240.0), 0, MaxHitSpellChance); + } + + internal static TimeSpan GetDuration(AosWeaponAttribute attribute) => + attribute switch + { + // Publish 65 states that duration scales with the selected hit spell's level. + // The issue does not provide an official per-level table, so keep the chosen + // deterministic current-SA policy explicit and capped at the 150-second maximum. + AosWeaponAttribute.HitMagicArrow => TimeSpan.FromSeconds(30), + AosWeaponAttribute.HitHarm => TimeSpan.FromSeconds(60), + AosWeaponAttribute.HitFireball => TimeSpan.FromSeconds(90), + AosWeaponAttribute.HitLightning => TimeSpan.FromSeconds(120), + AosWeaponAttribute.HitDispel => TimeSpan.FromSeconds(150), + _ => TimeSpan.Zero + }; + + internal static int GetHitSpellBonus(BaseWeapon weapon, AosWeaponAttribute attribute) => + _table.TryGetValue(weapon, out var timer) && timer.Attribute == attribute ? timer.Value : 0; + + internal static bool ProvidesSpellChanneling(BaseWeapon weapon, Mobile caster) => + _table.TryGetValue(weapon, out var timer) && timer.Caster == caster && timer.Weapon.Parent == caster && + timer.GrantsSpellChanneling; + + internal static int GetFasterCasting(Mobile caster) + { + foreach (var timer in _table.Values) + { + if (timer.Caster == caster && timer.Weapon?.Deleted == false && timer.Weapon.Parent == caster) + { + return timer.GrantsFasterCasting ? -1 : 0; + } + } + + return 0; + } + + public static bool IsEnchanted(BaseWeapon weapon) => weapon != null && _table.ContainsKey(weapon); + + public static void StopEffect(BaseWeapon weapon) => StopEffect(weapon, false); + + internal static void ExpireForTests(BaseWeapon weapon) => StopEffect(weapon, true); + + private static void StopEffect(BaseWeapon weapon, bool expired) + { + if (weapon == null || !_table.Remove(weapon, out var timer)) + { + return; + } + + timer.Stop(); + var caster = timer.Caster; + + if (expired && caster?.Deleted == false) + { + caster.SendLocalizedMessage(1115273); // The enchantment on your weapon has expired. + caster.PlaySound(0x1E6); + } + + (caster as PlayerMobile)?.RemoveBuff(BuffIcon.Enchant); + timer.ClearReferences(); + + if (!weapon.Deleted) + { + weapon.InvalidateProperties(); + } + } + + [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] + [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] + [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] + [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] + public static void OnCasterRemoved(Mobile caster) + { + var weapons = new List(); + + foreach (var pair in _table) + { + if (pair.Value.Caster == caster) + { + weapons.Add(pair.Key); + } + } + + for (var i = 0; i < weapons.Count; i++) + { + StopEffect(weapons[i]); + } + } + + private static void OnLogout(Mobile caster) => OnCasterRemoved(caster); + + private static EnchantmentTimer FindEffect(Mobile caster) + { + foreach (var timer in _table.Values) + { + if (timer.Caster == caster) + { + return timer; + } + } + + return null; + } + + private bool ValidateWeapon(BaseWeapon weapon) + { + if (weapon == null || weapon.Deleted || weapon is Fists) + { + return false; + } + + if (FindEffect(Caster) != null || _table.ContainsKey(weapon)) + { + Caster.SendLocalizedMessage(501775); // You already have an enchantment on a weapon. + return false; + } + + if (weapon.Cursed || weapon.Consecrated || ImmolatingWeaponSpell.IsImmolating(weapon)) + { + Caster.SendLocalizedMessage(1080128); // You cannot enchant that weapon. + return false; + } + + if (weapon.Parent is Mobile wielder && SpecialMove.GetCurrentMove(wielder) is FocusAttack) + { + Caster.SendLocalizedMessage(1080446); // You cannot enchant your weapon while using Focus Attack. + return false; + } + + for (var i = 0; i < _hitSpellAttributes.Length; i++) + { + if (weapon.WeaponAttributes[_hitSpellAttributes[i]] > 0) + { + Caster.SendLocalizedMessage(1080127); // That weapon already has a hit spell. + return false; + } + } + + return true; + } + + private static int GetBuffCliloc(AosWeaponAttribute attribute) => + attribute switch + { + AosWeaponAttribute.HitLightning => 1060423, + AosWeaponAttribute.HitFireball => 1060420, + AosWeaponAttribute.HitHarm => 1060421, + AosWeaponAttribute.HitMagicArrow => 1060426, + AosWeaponAttribute.HitDispel => 1060417, + _ => 0 + }; + + private sealed class EnchantmentTimer : Timer + { + public EnchantmentTimer( + Mobile caster, + BaseWeapon weapon, + AosWeaponAttribute attribute, + int value, + bool grantsSpellChanneling, + bool grantsFasterCasting, + TimeSpan duration + ) : base(duration) + { + Caster = caster; + Weapon = weapon; + Attribute = attribute; + Value = value; + GrantsSpellChanneling = grantsSpellChanneling; + GrantsFasterCasting = grantsFasterCasting; + } + + public Mobile Caster { get; private set; } + + public BaseWeapon Weapon { get; private set; } + + public AosWeaponAttribute Attribute { get; } + + public int Value { get; } + + public bool GrantsSpellChanneling { get; } + + public bool GrantsFasterCasting { get; } + + protected override void OnTick() => StopEffect(Weapon, true); + + public void ClearReferences() + { + Caster = null; + Weapon = null; + } + } +}