feat: implement Spell Trigger

This commit is contained in:
Crome696 2026-07-10 15:56:58 +02:00
parent bfd6284eac
commit 0f2d83eb3d
8 changed files with 1058 additions and 3 deletions

View file

@ -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<SpellTriggerSpell>(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<SpellStone>();
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<DragonsBlood>().Amount);
Assert.Equal(9, caster.Backpack.FindItemByType<Garlic>().Amount);
Assert.Equal(9, caster.Backpack.FindItemByType<MandrakeRoot>().Amount);
Assert.Equal(9, caster.Backpack.FindItemByType<SpidersSilk>().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<int>.Empty,
ReadOnlySpan<ushort>.Empty,
ReadOnlySpan<Range>.Empty,
ReadOnlySpan<byte>.Empty
);
gump.OnResponse(null, in relay);
Assert.Null(caster.Spell);
Assert.Equal(SpellState.None, spell.State);
Assert.Null(caster.Backpack.FindItemByType<SpellStone>());
Assert.Equal(100, caster.Mana);
Assert.Equal(10, caster.Backpack.FindItemByType<DragonsBlood>().Amount);
Assert.Equal(10, caster.Backpack.FindItemByType<Garlic>().Amount);
Assert.Equal(10, caster.Backpack.FindItemByType<MandrakeRoot>().Amount);
Assert.Equal(10, caster.Backpack.FindItemByType<SpidersSilk>().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<int>.Empty,
ReadOnlySpan<ushort>.Empty,
ReadOnlySpan<Range>.Empty,
ReadOnlySpan<byte>.Empty
);
gump.OnResponse(null, in relay);
Assert.Equal(677, caster.Backpack.FindItemByType<SpellStone>()?.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<byte>(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<NetherBoltSpell>(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<HealingStoneSpell>(caster.Spell);
spell.State = SpellState.Sequencing;
spell.OnCast();
Assert.Null(caster.Target);
Assert.Null(caster.Spell);
Assert.Equal(10, caster.Backpack.FindItemByType<Bone>().Amount);
Assert.Equal(10, caster.Backpack.FindItemByType<Garlic>().Amount);
Assert.Equal(10, caster.Backpack.FindItemByType<Ginseng>().Amount);
Assert.Equal(10, caster.Backpack.FindItemByType<SpidersSilk>().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<Type, int>)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;
}
}

View file

@ -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<SpellTriggerDefinition> 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<SpellTriggerDefinition> 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<SpellTriggerGump>();
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); // <center>Spell Trigger Selection Menu</center>
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);
}
}

View file

@ -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<ContextMenuEntry> 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();
}
}
}

View file

@ -0,0 +1,4 @@
{
"version": 0,
"type": "Server.Items.SpellStone"
}

View file

@ -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)))
{

View file

@ -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));

View file

@ -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;
}

View file

@ -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<Mobile, DateTime> _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<SpellTriggerDefinition> 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<SpellTriggerGump>();
_selectionTimer.Cancel();
base.OnDisturb(type, message);
}
public override void FinishSequence()
{
_selectionTimer.Cancel();
Caster.CloseGump<SpellTriggerGump>();
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<SpellStone>())
{
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<SpellTriggerGump>();
}
}
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<SpellTriggerDefinition> GetEligibleDefinitions(Mobile caster)
{
var definitions = new List<SpellTriggerDefinition>();
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;
}