From 0f2d83eb3de377b2e8c4f0682fdf6881cc98f115 Mon Sep 17 00:00:00 2001 From: Crome696 Date: Fri, 10 Jul 2026 15:56:58 +0200 Subject: [PATCH] feat: implement Spell Trigger --- .../Mysticism/SpellTriggerSpellTests.cs | 550 ++++++++++++++++++ Projects/UOContent/Gumps/SpellTriggerGump.cs | 88 +++ .../Items/Skill Items/Magical/SpellStone.cs | 77 +++ .../Server.Items.SpellStone.v0.json | 4 + Projects/UOContent/Spells/Base/Spell.cs | 3 +- Projects/UOContent/Spells/Initializer.cs | 2 +- .../UOContent/Spells/Mysticism/MysticSpell.cs | 2 +- .../Spells/Mysticism/SpellTriggerSpell.cs | 335 +++++++++++ 8 files changed, 1058 insertions(+), 3 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Spells/Mysticism/SpellTriggerSpellTests.cs create mode 100644 Projects/UOContent/Gumps/SpellTriggerGump.cs create mode 100644 Projects/UOContent/Items/Skill Items/Magical/SpellStone.cs create mode 100644 Projects/UOContent/Migrations/Server.Items.SpellStone.v0.json create mode 100644 Projects/UOContent/Spells/Mysticism/SpellTriggerSpell.cs diff --git a/Projects/UOContent.Tests/Tests/Spells/Mysticism/SpellTriggerSpellTests.cs b/Projects/UOContent.Tests/Tests/Spells/Mysticism/SpellTriggerSpellTests.cs new file mode 100644 index 000000000..c57863828 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Spells/Mysticism/SpellTriggerSpellTests.cs @@ -0,0 +1,550 @@ +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 Xunit; + +namespace Server.Tests.Spells.Mysticism; + +[Collection("Sequential UOContent Tests")] +public class SpellTriggerSpellTests +{ + [Fact] + public void SpellMetadata_MatchesFifthCircleMysticismSources() + { + var caster = NewCaster(); + var spell = new SpellTriggerSpell(caster); + + Assert.Equal("Spell Trigger", spell.Name); + Assert.Equal("In Vas Ort Ex", spell.Mantra); + Assert.Equal(SpellCircle.Fifth, spell.Circle); + Assert.Equal(TimeSpan.FromSeconds(1.5), spell.CastDelayBase); + Assert.Equal(14, spell.GetMana()); + Assert.Equal(45.0, spell.RequiredSkill); + Assert.Equal(SkillName.Mysticism, spell.CastSkill); + Assert.Equal( + [Reagent.DragonsBlood, Reagent.Garlic, Reagent.MandrakeRoot, Reagent.SpidersSilk], + spell.Reagents + ); + + caster.Delete(); + } + + [Fact] + public void MysticSpellbookAndScroll_UseSpellTriggerSlot() + { + var book = new MysticSpellbook(1UL << (685 - 677)); + var scroll = new SpellTriggerScroll(); + + Assert.Equal(677, book.BookOffset); + Assert.Equal(16, book.BookCount); + Assert.True(book.HasSpell(685)); + Assert.Equal(685, GetSpellScrollId(scroll)); + + book.Delete(); + scroll.Delete(); + } + + [Fact] + public void RegisterMysticism_PreSaDoesNotExposeSpellTrigger() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.ML; + Initializer.Configure(); + + Assert.Null(SpellRegistry.NewSpell(685, caster, null)); + Assert.Equal(-1, SpellRegistry.GetRegistryNumber(typeof(SpellTriggerSpell))); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void RegisterMysticism_SaExposesSpellTriggerAtSpellId685() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.SA; + Initializer.Configure(); + + Assert.Same(typeof(SpellTriggerSpell), SpellRegistry.Types[685]); + Assert.Equal(685, SpellRegistry.GetRegistryNumber(typeof(SpellTriggerSpell))); + Assert.IsType(SpellRegistry.NewSpell(685, caster, null)); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void MaximumStorableCircle_UsesCombinedMysticismAndSupportSkillAndCapsAtSix() + { + var caster = NewCaster(20.0, 20.0); + var cappedCaster = NewCaster(); + + Assert.Equal(1, SpellTriggerSpell.GetMaximumStorableCircle(caster)); + Assert.Equal(6, SpellTriggerSpell.GetMaximumStorableCircle(cappedCaster)); + + caster.Delete(); + cappedCaster.Delete(); + } + + [Fact] + public void EligibleDefinitions_RequireRegistrationKnowledgeAndStorageRank() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + MysticSpellbook book = null; + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.SA; + Initializer.Configure(); + book = AddMysticBook(caster, 677, 678, 679, 680, 682, 683, 684, 687, 688, 689, 690, 691); + + var definitions = SpellTriggerSpell.GetEligibleDefinitions(caster); + var spellIds = new int[definitions.Count]; + + for (var i = 0; i < definitions.Count; i++) + { + spellIds[i] = definitions[i].SpellId; + } + + Assert.Equal([677, 678, 679, 680, 682, 683, 684, 687, 688], spellIds); + } + finally + { + book?.Delete(); + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void CheckCast_EnforcesSaSkillManaAndEligibleSpellRequirements() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + var book = AddMysticBook(caster, 677); + caster.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.SA; + Initializer.Configure(); + + var spell = new SpellTriggerSpell(caster); + Assert.True(spell.CheckCast()); + + caster.Skills.Mysticism.Base = 44.9; + Assert.False(spell.CheckCast()); + + caster.Skills.Mysticism.Base = 120.0; + caster.Mana = 0; + Assert.False(spell.CheckCast()); + + caster.Mana = caster.ManaMax; + Core.Expansion = Expansion.ML; + Assert.False(spell.CheckCast()); + } + finally + { + book.Delete(); + caster.Delete(); + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + } + } + + [Fact] + public void Selection_CreatesOneStoneReplacesPreviousStoneAndConsumesResources() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + var book = AddMysticBook(caster, 677); + var previousStone = new SpellStone(684); + caster.AddToBackpack(previousStone); + AddReagents(caster); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.SA; + Initializer.Configure(); + + var spell = BeginSelection(caster); + spell.FinishSelection(677); + + var stone = caster.Backpack.FindItemByType(); + Assert.NotNull(stone); + Assert.Equal(677, stone.SpellId); + Assert.True(previousStone.Deleted); + Assert.Null(caster.Spell); + Assert.Equal(SpellState.None, spell.State); + Assert.Equal(86, caster.Mana); + Assert.Equal(9, caster.Backpack.FindItemByType().Amount); + Assert.Equal(9, caster.Backpack.FindItemByType().Amount); + Assert.Equal(9, caster.Backpack.FindItemByType().Amount); + Assert.Equal(9, caster.Backpack.FindItemByType().Amount); + } + finally + { + book.Delete(); + caster.Delete(); + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + } + } + + [Fact] + public void SelectionCancel_DoesNotConsumeResourcesOrCreateStone() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + var book = AddMysticBook(caster, 677); + AddReagents(caster); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.SA; + Initializer.Configure(); + + var spell = BeginSelection(caster); + var gump = new SpellTriggerGump(spell, [SpellTriggerSpell.Definitions[0]]); + var relay = new RelayInfo( + 0, + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + ReadOnlySpan.Empty + ); + + gump.OnResponse(null, in relay); + + Assert.Null(caster.Spell); + Assert.Equal(SpellState.None, spell.State); + Assert.Null(caster.Backpack.FindItemByType()); + 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); + Assert.Equal(10, caster.Backpack.FindItemByType().Amount); + } + finally + { + book.Delete(); + caster.Delete(); + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + } + } + + [Fact] + public void SelectionGumpResponse_CreatesSelectedStone() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + var book = AddMysticBook(caster, 677); + AddReagents(caster); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.SA; + Initializer.Configure(); + + var spell = BeginSelection(caster); + var gump = new SpellTriggerGump(spell, [SpellTriggerSpell.Definitions[0]]); + var relay = new RelayInfo( + 100, + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + ReadOnlySpan.Empty, + ReadOnlySpan.Empty + ); + + gump.OnResponse(null, in relay); + + Assert.Equal(677, caster.Backpack.FindItemByType()?.SpellId); + Assert.Null(caster.Spell); + } + finally + { + book.Delete(); + caster.Delete(); + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + } + } + + [Fact] + public void SelectionGump_CompilesWithVisibleLayout() + { + var caster = NewCaster(); + var spell = new SpellTriggerSpell(caster); + var gump = new SpellTriggerGump(spell, SpellTriggerSpell.Definitions); + var buffer = GC.AllocateUninitializedArray(4096); + var writer = new SpanWriter(buffer); + gump.Compile(ref writer); + + Assert.True(writer.BytesWritten > 0); + + caster.Delete(); + } + + [Fact] + public void SpellStoneActivation_UsesNormalSpellCastAndStartsCooldown() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + var book = AddMysticBook(caster, 677); + var stone = new SpellStone(677); + var target = NewCaster(); + caster.AddToBackpack(stone); + caster.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); + target.MoveToWorld(new Point3D(101, 100, 0), Map.Felucca); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.SA; + Initializer.Configure(); + + Assert.True(stone.TryUseForTests(caster)); + Assert.True(stone.Deleted); + Assert.True(SpellTriggerSpell.IsOnCooldown(caster, out var remaining)); + Assert.InRange(remaining.TotalSeconds, 299, SpellTriggerSpell.CooldownSeconds); + + var spell = Assert.IsType(caster.Spell); + spell.State = SpellState.Sequencing; + spell.OnCast(); + Assert.NotNull(caster.Target); + spell.Target(target); + spell.FinishSequence(); + } + finally + { + SpellTriggerSpell.ClearCooldownForTests(caster); + book.Delete(); + caster.Delete(); + target.Delete(); + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + } + } + + [Fact] + public void SpellStoneActivation_UsesNormalSelfAndNoTargetSpellPipeline() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + var book = AddMysticBook(caster, 678); + var stone = new SpellStone(678); + caster.AddToBackpack(stone); + caster.AddToBackpack(new Bone(10)); + caster.AddToBackpack(new Garlic(10)); + caster.AddToBackpack(new Ginseng(10)); + caster.AddToBackpack(new SpidersSilk(10)); + caster.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.SA; + Initializer.Configure(); + + Assert.True(stone.TryUseForTests(caster)); + var spell = Assert.IsType(caster.Spell); + spell.State = SpellState.Sequencing; + spell.OnCast(); + + Assert.Null(caster.Target); + Assert.Null(caster.Spell); + Assert.Equal(10, caster.Backpack.FindItemByType().Amount); + Assert.Equal(10, caster.Backpack.FindItemByType().Amount); + Assert.Equal(10, caster.Backpack.FindItemByType().Amount); + Assert.Equal(10, caster.Backpack.FindItemByType().Amount); + } + finally + { + SpellTriggerSpell.ClearCooldownForTests(caster); + book.Delete(); + caster.Delete(); + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + } + } + + [Fact] + public void SpellStoneActivation_RejectsUnknownStoredSpellAndHonorsCooldown() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + var stone = new SpellStone(677); + caster.AddToBackpack(stone); + var secondStone = new SpellStone(677); + caster.AddToBackpack(secondStone); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.SA; + Initializer.Configure(); + + Assert.False(stone.TryUseForTests(caster)); + Assert.False(stone.Deleted); + + var book = AddMysticBook(caster, 677); + Assert.True(stone.TryUseForTests(caster)); + caster.Spell?.FinishSequence(); + Assert.True(stone.Deleted); + + Assert.False(secondStone.TryUseForTests(caster)); + Assert.False(secondStone.Deleted); + + EventSink.InvokeLogout(caster); + Assert.True(secondStone.TryUseForTests(caster)); + caster.Spell?.FinishSequence(); + book.Delete(); + } + finally + { + SpellTriggerSpell.ClearCooldownForTests(caster); + caster.Delete(); + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + } + } + + [Fact] + public void SpellStone_IsBlessedNontransferableAndDestroyedWhenDropped() + { + var owner = NewCaster(); + var other = NewCaster(); + var stone = new SpellStone(677); + owner.AddToBackpack(stone); + + Assert.Equal(LootType.Blessed, stone.LootType); + Assert.True(stone.Nontransferable); + Assert.False(stone.AllowSecureTrade(owner, other, owner, true)); + Assert.False(stone.DropToWorld(owner, Point3D.Zero)); + Assert.True(stone.Deleted); + + owner.Delete(); + other.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 TestSpellTriggerSpell BeginSelection(PlayerMobile caster) + { + var spell = new TestSpellTriggerSpell(caster); + caster.Spell = spell; + spell.State = SpellState.Sequencing; + return spell; + } + + private static MysticSpellbook AddMysticBook(PlayerMobile caster, params int[] spellIds) + { + ulong content = 0; + + for (var i = 0; i < spellIds.Length; i++) + { + content |= 1UL << (spellIds[i] - 677); + } + + var book = new MysticSpellbook(content); + caster.AddToBackpack(book); + return book; + } + + private static void AddReagents(Mobile caster) + { + caster.AddToBackpack(new DragonsBlood(10)); + caster.AddToBackpack(new Garlic(10)); + caster.AddToBackpack(new MandrakeRoot(10)); + caster.AddToBackpack(new SpidersSilk(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 TestSpellTriggerSpell : SpellTriggerSpell + { + public TestSpellTriggerSpell(Mobile caster) : base(caster) + { + } + + public override bool CheckFizzle() => true; + } +} diff --git a/Projects/UOContent/Gumps/SpellTriggerGump.cs b/Projects/UOContent/Gumps/SpellTriggerGump.cs new file mode 100644 index 000000000..4eb95fae0 --- /dev/null +++ b/Projects/UOContent/Gumps/SpellTriggerGump.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using Server.Items; +using Server.Network; +using Server.Spells; +using Server.Spells.Mysticism; + +namespace Server.Gumps; + +public sealed class SpellTriggerGump : DynamicGump +{ + private readonly SpellTriggerSpell _spell; + private readonly SpellTriggerDefinition[] _definitions; + + internal SpellTriggerGump(SpellTriggerSpell spell, IReadOnlyList definitions) : base(20, 20) + { + _spell = spell; + _definitions = new SpellTriggerDefinition[definitions.Count]; + + for (var i = 0; i < definitions.Count; i++) + { + _definitions[i] = definitions[i]; + } + } + + public override bool Singleton => true; + + public static bool DisplayTo( + Mobile from, + SpellTriggerSpell spell, + IReadOnlyList definitions + ) + { + if (from == null || from.Deleted || from.NetState == null || spell == null || spell.Caster != from || + from.Spell != spell || spell.State != SpellState.Sequencing || definitions == null || definitions.Count == 0) + { + return false; + } + + from.CloseGump(); + from.SendGump(new SpellTriggerGump(spell, definitions)); + return true; + } + + protected override void BuildLayout(ref DynamicGumpBuilder builder) + { + builder.AddPage(); + builder.AddBackground(0, 0, 520, 404, 0x13BE); + builder.AddImageTiled(10, 10, 500, 20, 0xA40); + builder.AddImageTiled(10, 40, 500, 324, 0xA40); + builder.AddImageTiled(10, 374, 500, 20, 0xA40); + builder.AddAlphaRegion(10, 10, 500, 384); + builder.AddHtmlLocalized(14, 12, 500, 20, 1080151, 0x7FFF); //
Spell Trigger Selection Menu
+ + builder.AddButton(10, 374, 0xFB1, 0xFB2, 0); + builder.AddLabel(45, 376, 0x7FFF, "Cancel"); + + for (var i = 0; i < _definitions.Length; i++) + { + var definition = _definitions[i]; + var row = i / 2; + var column = i % 2; + var x = 14 + column * 250; + var y = 44 + row * 64; + + builder.AddImageTiledButton(x, y, 0x918, 0x919, 100 + i, GumpButtonType.Reply, 0, definition.ItemId, 0, 15, 20); + builder.AddLabel(x + 84, y, 0x7FFF, definition.Name); + builder.AddLabel(x + 84, y + 22, 0x7FFF, $"Circle {definition.Rank}"); + } + } + + public override void OnResponse(NetState sender, in RelayInfo info) + { + if (sender != null && sender.Mobile != _spell.Caster) + { + return; + } + + var index = info.ButtonID - 100; + + if (index < 0 || index >= _definitions.Length) + { + _spell.CancelSelection(); + return; + } + + _spell.FinishSelection(_definitions[index].SpellId); + } +} diff --git a/Projects/UOContent/Items/Skill Items/Magical/SpellStone.cs b/Projects/UOContent/Items/Skill Items/Magical/SpellStone.cs new file mode 100644 index 000000000..3ab034307 --- /dev/null +++ b/Projects/UOContent/Items/Skill Items/Magical/SpellStone.cs @@ -0,0 +1,77 @@ +using ModernUO.Serialization; +using Server.Collections; +using Server.ContextMenus; +using Server.Multis; +using Server.Spells.Mysticism; + +namespace Server.Items; + +[SerializationGenerator(0, false)] +public partial class SpellStone : SpellScroll +{ + public const int ItemID = 0x4079; + + [Constructible] + public SpellStone(int spellId) : base(spellId, ItemID, 1) + { + Stackable = false; + LootType = LootType.Blessed; + } + + public int SpellId => SpellID; + + public override string DefaultName => "a spell stone"; + + public override bool Nontransferable => true; + + public override void GetContextMenuEntries(Mobile from, ref PooledRefList list) + { + } + + public override void GetProperties(IPropertyList list) + { + base.GetProperties(list); + + var definition = SpellTriggerSpell.GetDefinition(SpellId); + + if (definition != null) + { + list.Add(1080166, definition.Name); // Use: ~1_spellName~ + } + } + + public override void OnDoubleClick(Mobile from) + { + if (!DesignContext.Check(from)) + { + return; + } + + if (!IsChildOf(from.Backpack)) + { + from.SendLocalizedMessage(1042001); // That must be in your pack for you to use it. + return; + } + + SpellTriggerSpell.TryUseStone(this, from); + } + + internal bool TryUseForTests(Mobile from) => SpellTriggerSpell.TryUseStone(this, from); + + public override bool AllowSecureTrade(Mobile from, Mobile to, Mobile newOwner, bool accepted) => false; + + public override bool DropToWorld(Mobile from, Point3D p) + { + Delete(); + return false; + } + + [AfterDeserialization(false)] + private void AfterDeserialization() + { + if (SpellTriggerSpell.GetDefinition(SpellId) == null) + { + Delete(); + } + } +} diff --git a/Projects/UOContent/Migrations/Server.Items.SpellStone.v0.json b/Projects/UOContent/Migrations/Server.Items.SpellStone.v0.json new file mode 100644 index 000000000..732c52b16 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.SpellStone.v0.json @@ -0,0 +1,4 @@ +{ + "version": 0, + "type": "Server.Items.SpellStone" +} \ No newline at end of file diff --git a/Projects/UOContent/Spells/Base/Spell.cs b/Projects/UOContent/Spells/Base/Spell.cs index 5ab288ec5..2c96d932c 100644 --- a/Projects/UOContent/Spells/Base/Spell.cs +++ b/Projects/UOContent/Spells/Base/Spell.cs @@ -760,7 +760,8 @@ namespace Server.Spells { DoFizzle(); } - else if (Scroll != null && Scroll is not Runebook && + else if (Scroll != null && Scroll is not Runebook && Scroll is not SpellStone && + // Spell Stones are consumed at activation but still run the stored spell's sequence. (Scroll.Amount <= 0 || Scroll.Deleted || Scroll.RootParent != Caster || Scroll is BaseWand baseWand && (baseWand.Charges <= 0 || baseWand.Parent != Caster))) { diff --git a/Projects/UOContent/Spells/Initializer.cs b/Projects/UOContent/Spells/Initializer.cs index 49eae3272..a1cf11ea4 100644 --- a/Projects/UOContent/Spells/Initializer.cs +++ b/Projects/UOContent/Spells/Initializer.cs @@ -189,7 +189,7 @@ namespace Server.Spells Register(682, typeof(EagleStrikeSpell)); Register(683, typeof(AnimatedWeaponSpell)); Register(684, typeof(StoneFormSpell)); - // Register(685, typeof(SpellTriggerSpell)); + Register(685, typeof(SpellTriggerSpell)); // Register(686, typeof(MassSleepSpell)); Register(687, typeof(CleansingWindsSpell)); Register(688, typeof(BombardSpell)); diff --git a/Projects/UOContent/Spells/Mysticism/MysticSpell.cs b/Projects/UOContent/Spells/Mysticism/MysticSpell.cs index 68a8f54d4..d74e8e642 100644 --- a/Projects/UOContent/Spells/Mysticism/MysticSpell.cs +++ b/Projects/UOContent/Spells/Mysticism/MysticSpell.cs @@ -22,7 +22,7 @@ public abstract class MysticSpell : Spell { var circle = (int)Circle; - if (Scroll != null) + if (Scroll != null && Scroll is not SpellStone) { circle -= 2; } diff --git a/Projects/UOContent/Spells/Mysticism/SpellTriggerSpell.cs b/Projects/UOContent/Spells/Mysticism/SpellTriggerSpell.cs new file mode 100644 index 000000000..e287d41e0 --- /dev/null +++ b/Projects/UOContent/Spells/Mysticism/SpellTriggerSpell.cs @@ -0,0 +1,335 @@ +using System; +using System.Collections.Generic; +using ModernUO.CodeGeneratedEvents; +using Server.Gumps; +using Server.Items; +using Server.Mobiles; + +namespace Server.Spells.Mysticism; + +public class SpellTriggerSpell : MysticSpell +{ + public const int SpellId = 685; + public const int MaximumStorableCircle = 6; + public const int CooldownSeconds = 300; + + private static readonly SpellInfo _info = new( + "Spell Trigger", + "In Vas Ort Ex", + 230, + 9022, + Reagent.DragonsBlood, + Reagent.Garlic, + Reagent.MandrakeRoot, + Reagent.SpidersSilk + ); + + private static readonly SpellTriggerDefinition[] _definitions = + [ + new(677, "Nether Bolt", SpellCircle.First, 0x2D9E), + new(678, "Healing Stone", SpellCircle.First, 0x2D9F), + new(679, "Purge Magic", SpellCircle.Second, 0x2DA0), + new(680, "Enchant", SpellCircle.Second, 0x2DA1), + new(682, "Eagle Strike", SpellCircle.Third, 0x2DA3), + new(683, "Animated Weapon", SpellCircle.Fourth, 0x2DA4), + new(684, "Stone Form", SpellCircle.Fourth, 0x2DA5), + new(687, "Cleansing Winds", SpellCircle.Sixth, 0x2DA8), + new(688, "Bombard", SpellCircle.Sixth, 0x2DA9) + ]; + + private static readonly Dictionary _cooldowns = new(); + + private TimerExecutionToken _selectionTimer; + + static SpellTriggerSpell() + { + EventSink.Logout += OnLogout; + } + + public SpellTriggerSpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info) + { + } + + public override SpellCircle Circle => SpellCircle.Fifth; + + public static IReadOnlyList Definitions => _definitions; + + public override bool CheckCast() + { + if (!Core.SA || !base.CheckCast()) + { + return false; + } + + if (Caster.Backpack == null) + { + Caster.SendLocalizedMessage(1042001); // That must be in your pack for you to use it. + return false; + } + + if (GetEligibleDefinitions(Caster).Count == 0) + { + Caster.SendMessage("You do not know any Mysticism spells that can be stored."); + return false; + } + + return true; + } + + public override void OnCast() + { + var definitions = GetEligibleDefinitions(Caster); + + if (definitions.Count == 0 || !SpellTriggerGump.DisplayTo(Caster, this, definitions)) + { + FinishSequence(); + return; + } + + 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(int spellId) + { + if (Caster.Spell != this || State != SpellState.Sequencing) + { + return; + } + + try + { + var definition = GetDefinition(spellId); + + if (definition == null || !IsEligible(Caster, definition)) + { + return; + } + + var newStone = new SpellStone(definition.SpellId); + + if (!Caster.PlaceInBackpack(newStone)) + { + newStone.Delete(); + Caster.SendMessage("There is not enough room in your backpack for a Spell Stone."); + return; + } + + if (!CheckSequence()) + { + newStone.Delete(); + return; + } + + using (var stones = Caster.Backpack.EnumerateItemsByType()) + { + foreach (var stone in stones) + { + if (stone != newStone) + { + stone.Delete(); + } + } + } + + Caster.PlaySound(0x659); + Caster.SendLocalizedMessage(1080165); // A Spell Stone appears in your backpack. + } + finally + { + FinishSequence(); + } + } + + internal void CancelSelection() + { + if (Caster.Spell == this && State == SpellState.Sequencing) + { + Caster.SendMessage("You decide not to create a Spell Stone."); + FinishSequence(); + } + else + { + _selectionTimer.Cancel(); + Caster.CloseGump(); + } + } + + internal static int GetMaximumStorableCircle(Mobile caster) + { + if (caster == null) + { + return 0; + } + + var skillTotal = GetBaseSkill(caster) + Math.Max(caster.Skills.Focus.Value, caster.Skills.Imbuing.Value); + return Math.Min(MaximumStorableCircle, (int)(skillTotal / 40.0)); + } + + internal static List GetEligibleDefinitions(Mobile caster) + { + var definitions = new List(); + var maximumCircle = GetMaximumStorableCircle(caster); + + if (caster == null || caster.Deleted || caster.Backpack == null || maximumCircle == 0) + { + return definitions; + } + + for (var i = 0; i < _definitions.Length; i++) + { + var definition = _definitions[i]; + + if (definition.Rank > maximumCircle || !IsRegistered(definition.SpellId) || !IsKnown(caster, definition.SpellId)) + { + continue; + } + + definitions.Add(definition); + } + + return definitions; + } + + internal static SpellTriggerDefinition GetDefinition(int spellId) + { + for (var i = 0; i < _definitions.Length; i++) + { + if (_definitions[i].SpellId == spellId) + { + return _definitions[i]; + } + } + + return null; + } + + internal static bool TryUseStone(SpellStone stone, Mobile caster) + { + if (stone == null || stone.Deleted || caster == null || caster.Deleted || !caster.Alive) + { + return false; + } + + if (caster.Backpack == null || !stone.IsChildOf(caster.Backpack)) + { + caster.SendLocalizedMessage(1042001); // That must be in your pack for you to use it. + return false; + } + + if (IsOnCooldown(caster, out var remaining)) + { + var seconds = Math.Max(1, (int)Math.Ceiling(remaining.TotalSeconds)); + caster.SendLocalizedMessage(1079263, seconds.ToString()); // You must wait ~1_seconds~ seconds before you can use this item. + return false; + } + + var definition = GetDefinition(stone.SpellId); + + if (definition == null || !IsRegistered(stone.SpellId)) + { + caster.SendLocalizedMessage(502345); // This spell has been temporarily disabled. + return false; + } + + if (!IsKnown(caster, stone.SpellId)) + { + caster.SendLocalizedMessage(500015); // You do not have that spell! + return false; + } + + var spell = SpellRegistry.NewSpell(stone.SpellId, caster, stone); + + if (spell == null || !spell.Cast()) + { + return false; + } + + _cooldowns[caster] = Core.Now.AddSeconds(CooldownSeconds); + stone.Delete(); + return true; + } + + internal static bool IsOnCooldown(Mobile caster, out TimeSpan remaining) + { + if (caster == null || !_cooldowns.TryGetValue(caster, out var cooldownUntil)) + { + remaining = TimeSpan.Zero; + return false; + } + + remaining = cooldownUntil - Core.Now; + + if (remaining <= TimeSpan.Zero) + { + _cooldowns.Remove(caster); + remaining = TimeSpan.Zero; + return false; + } + + return true; + } + + internal static void ClearCooldownForTests(Mobile caster) => ClearCooldown(caster); + + [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] + [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] + [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] + [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] + public static void OnCasterRemoved(Mobile caster) => ClearCooldown(caster); + + private static void OnLogout(Mobile caster) => ClearCooldown(caster); + + private static void ClearCooldown(Mobile caster) + { + if (caster != null) + { + _cooldowns.Remove(caster); + } + } + + private static bool IsEligible(Mobile caster, SpellTriggerDefinition definition) => + definition != null && + definition.Rank <= GetMaximumStorableCircle(caster) && + IsRegistered(definition.SpellId) && + IsKnown(caster, definition.SpellId); + + private static bool IsKnown(Mobile caster, int spellId) => Spellbook.Find(caster, spellId)?.HasSpell(spellId) == true; + + private static bool IsRegistered(int spellId) => + spellId >= 0 && spellId < SpellRegistry.Types.Length && SpellRegistry.Types[spellId] != null; +} + +public sealed class SpellTriggerDefinition +{ + public SpellTriggerDefinition(int spellId, string name, SpellCircle circle, int itemId) + { + SpellId = spellId; + Name = name; + Circle = circle; + ItemId = itemId; + } + + public int SpellId { get; } + + public string Name { get; } + + public SpellCircle Circle { get; } + + public int ItemId { get; } + + public int Rank => (int)Circle + 1; +}