diff --git a/Projects/UOContent.Tests/Tests/Spells/Mysticism/HealingStoneTests.cs b/Projects/UOContent.Tests/Tests/Spells/Mysticism/HealingStoneTests.cs new file mode 100644 index 000000000..7ebac925b --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Spells/Mysticism/HealingStoneTests.cs @@ -0,0 +1,323 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Server.Items; +using Server.Spells; +using Server.Spells.Mysticism; +using Xunit; + +namespace Server.Tests.Spells.Mysticism; + +[Collection("Sequential UOContent Tests")] +public class HealingStoneTests +{ + [Fact] + public void SpellMetadata_MatchesHealingStoneSources() + { + var caster = NewMysticCaster(); + var spell = new HealingStoneSpell(caster); + + Assert.Equal("Healing Stone", spell.Name); + Assert.Equal("Kal In Mani", spell.Mantra); + Assert.Equal(SpellCircle.First, spell.Circle); + Assert.Equal(TimeSpan.FromSeconds(5.0), spell.CastDelayBase); + Assert.Equal(4, spell.GetMana()); + Assert.Equal(0.0, spell.RequiredSkill); + Assert.Equal(SkillName.Mysticism, spell.CastSkill); + Assert.Equal([Reagent.Bone, Reagent.Garlic, Reagent.Ginseng, Reagent.SpidersSilk], spell.Reagents); + + caster.Delete(); + } + + [Fact] + public void MysticSpellbookAndScroll_UseHealingStoneSlot() + { + var book = new MysticSpellbook(1UL << (678 - 677)); + var scroll = new HealingStoneScroll(); + + Assert.Equal(677, book.BookOffset); + Assert.Equal(16, book.BookCount); + Assert.True(book.HasSpell(678)); + Assert.Equal(678, GetSpellScrollId(scroll)); + + book.Delete(); + scroll.Delete(); + } + + [Fact] + public void RegisterMysticism_PreSa_DoesNotExposeHealingStone() + { + var previousExpansion = Core.Expansion; + var caster = NewMysticCaster(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.ML; + Initializer.Configure(); + + Assert.Null(SpellRegistry.NewSpell(678, caster, null)); + Assert.Equal(-1, SpellRegistry.GetRegistryNumber(typeof(HealingStoneSpell))); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void RegisterMysticism_Sa_ExposesHealingStoneAtSpellId678() + { + var previousExpansion = Core.Expansion; + var caster = NewMysticCaster(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.SA; + Initializer.Configure(); + + Assert.Same(typeof(HealingStoneSpell), SpellRegistry.Types[678]); + Assert.Equal(678, SpellRegistry.GetRegistryNumber(typeof(HealingStoneSpell))); + Assert.IsType(SpellRegistry.NewSpell(678, caster, null)); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void CastingHealingStone_ConsumesResourcesAndCreatesOneOwnedStone() + { + var previousExpansion = Core.Expansion; + var caster = NewMysticCaster(); + AddReagents(caster); + + try + { + Core.Expansion = Expansion.SA; + var spell = new TestHealingStoneSpell(caster); + caster.Spell = spell; + spell.State = SpellState.Sequencing; + + spell.OnCast(); + + var stone = caster.Backpack.FindItemByType(); + Assert.NotNull(stone); + Assert.Same(caster, stone.Owner); + Assert.Equal(96, 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 + { + Core.Expansion = previousExpansion; + caster.Delete(); + } + } + + [Fact] + public void HealingStone_HealsOnlyOwnerAndRequiresBackpack() + { + var owner = NewMobile(); + var other = NewMobile(); + var stone = new HealingStone(owner, 100, 20); + owner.AddToBackpack(stone); + owner.Hits = owner.HitsMax - 20; + + try + { + Assert.False(stone.TryUseForTests(other)); + Assert.Equal(100, stone.LifeForce); + + owner.Hits = owner.HitsMax; + Assert.False(stone.TryUseForTests(owner)); + Assert.Equal(100, stone.LifeForce); + + owner.Hits = owner.HitsMax - 20; + Assert.True(stone.TryUseForTests(owner)); + Assert.Equal(owner.HitsMax, owner.Hits); + Assert.Equal(80, stone.LifeForce); + Assert.Equal(1, stone.AvailableHealing); + + owner.EndAction(); + stone.MoveToWorld(owner.Location, owner.Map); + Assert.False(stone.TryUseForTests(owner)); + } + finally + { + owner.Delete(); + other.Delete(); + stone.Delete(); + } + } + + [Fact] + public void HealingStone_RechargesOneUseOverFifteenSeconds() + { + var previousNow = Core.Now; + var owner = NewMobile(); + var stone = new HealingStone(owner, 100, 30); + owner.AddToBackpack(stone); + owner.Hits = owner.HitsMax - 30; + + try + { + Assert.True(stone.TryUseForTests(owner)); + owner.EndAction(); + Assert.Equal(1, stone.AvailableHealing); + + Core._now = previousNow.AddSeconds(HealingStone.FullRechargeSeconds); + Assert.Equal(stone.MaxHealing, stone.AvailableHealing); + } + finally + { + Core._now = previousNow; + owner.Delete(); + stone.Delete(); + } + } + + [Fact] + public void HealingStone_RejectsSecureTradeAndDeletesWhenDropped() + { + var owner = NewMobile(); + var other = NewMobile(); + var stone = new HealingStone(owner, 100, 20); + owner.AddToBackpack(stone); + + Assert.False(stone.AllowSecureTrade(owner, other, owner, true)); + Assert.False(stone.DropToWorld(owner, Point3D.Zero)); + Assert.True(stone.Deleted); + + owner.Delete(); + other.Delete(); + } + + [Fact] + public void HealingStone_CureCostAndChanceScaleByPoisonLevel() + { + Assert.Equal(0, HealingStone.GetCureCost(0)); + Assert.Equal(25, HealingStone.GetCureCost(1)); + Assert.Equal(100, HealingStone.GetCureCost(4)); + Assert.Equal(120, HealingStone.GetCureCost(5)); + Assert.Equal(170.0, HealingStone.GetCureChance(120.0, 120.0, 0)); + Assert.Equal(90.0, HealingStone.GetCureChance(120.0, 120.0, 4)); + } + + [Fact] + public void HealingPotion_ResetsStonePerUseHealing() + { + var previousNow = Core.Now; + var owner = NewMobile(); + var stone = new HealingStone(owner, 100, 20); + owner.AddToBackpack(stone); + owner.Hits = owner.HitsMax - 20; + + Assert.True(stone.TryUseForTests(owner)); + owner.EndAction(); + Assert.Equal(1, stone.AvailableHealing); + + Core._now = previousNow.AddSeconds(HealingStone.FullRechargeSeconds); + Assert.Equal(stone.MaxHealing, stone.AvailableHealing); + + var potion = new TestHealPotion(); + owner.Hits = owner.HitsMax - 10; + potion.DoHeal(owner); + + Assert.Equal(1, stone.AvailableHealing); + + potion.Delete(); + owner.Delete(); + stone.Delete(); + Core._now = previousNow; + } + + private static Mobile NewMobile(bool withBackpack = true) + { + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.Player = true; + mobile.InitStats(100, 100, 100); + mobile.Hits = mobile.HitsMax; + mobile.Mana = mobile.ManaMax; + + if (withBackpack) + { + mobile.AddItem(new Backpack()); + } + + return mobile; + } + + private static Mobile NewMysticCaster() + { + var caster = NewMobile(); + caster.Skills.Mysticism.Base = 120.0; + caster.Skills.Focus.Base = 120.0; + caster.Skills.Imbuing.Base = 0.0; + return caster; + } + + private static void AddReagents(Mobile caster) + { + caster.AddToBackpack(new Bone(10)); + caster.AddToBackpack(new Garlic(10)); + caster.AddToBackpack(new Ginseng(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 TestHealingStoneSpell : HealingStoneSpell + { + public TestHealingStoneSpell(Mobile caster) : base(caster) + { + } + + public override bool CheckFizzle() => true; + } + + private sealed class TestHealPotion : BaseHealPotion + { + public TestHealPotion() : base(PotionEffect.Heal) + { + } + + public override int MinHeal => 10; + public override int MaxHeal => 10; + public override double Delay => 10.0; + } +} diff --git a/Projects/UOContent/Items/Skill Items/Magical/HealingStone.cs b/Projects/UOContent/Items/Skill Items/Magical/HealingStone.cs new file mode 100644 index 000000000..694a92acb --- /dev/null +++ b/Projects/UOContent/Items/Skill Items/Magical/HealingStone.cs @@ -0,0 +1,286 @@ +using System; +using ModernUO.Serialization; +using Server.Engines.ConPVP; +using Server.Spells; +using Server.Spells.Mysticism; + +namespace Server.Items; + +[SerializationGenerator(0)] +public partial class HealingStone : Item +{ + public const int ItemID = 0x4078; + public const int FullRechargeSeconds = 15; + public const int UseCooldownSeconds = 2; + + [SerializableField(0)] + private Mobile _ownerMobile; + + [SerializableField(1)] + [InvalidateProperties] + private int _storedLifeForce; + + [SerializableField(2)] + private int _maximumLifeForce; + + [SerializableField(3)] + [InvalidateProperties] + private int _healingAvailable; + + [SerializableField(4)] + private int _maximumHealing; + + [SerializableField(5)] + private DateTime _rechargeStarted; + + [Constructible] + public HealingStone(Mobile owner, int lifeForce, int maxHealing) : base(ItemID) + { + _ownerMobile = owner; + _maximumLifeForce = Math.Max(0, lifeForce); + _storedLifeForce = _maximumLifeForce; + _maximumHealing = Math.Max(1, maxHealing); + _healingAvailable = _maximumHealing; + _rechargeStarted = Core.Now; + + LootType = LootType.Blessed; + } + + public override double DefaultWeight => 1.0; + + public override string DefaultName => "a healing stone"; + + public override bool Nontransferable => true; + + [CommandProperty(AccessLevel.GameMaster)] + public Mobile Owner => _ownerMobile; + + [CommandProperty(AccessLevel.GameMaster)] + public int LifeForce + { + get => _storedLifeForce; + set + { + _storedLifeForce = Math.Clamp(value, 0, _maximumLifeForce); + InvalidateProperties(); + + if (_storedLifeForce <= 0) + { + Delete(); + } + } + } + + [CommandProperty(AccessLevel.GameMaster)] + public int MaxLifeForce => _maximumLifeForce; + + [CommandProperty(AccessLevel.GameMaster)] + public int MaxHealing => _maximumHealing; + + [CommandProperty(AccessLevel.GameMaster)] + public int AvailableHealing + { + get + { + Replenish(); + return _healingAvailable; + } + } + + public override void GetProperties(IPropertyList list) + { + base.GetProperties(list); + list.Add(1115274, _storedLifeForce); + } + + public override void OnDoubleClick(Mobile from) => TryUse(from); + + internal bool TryUseForTests(Mobile from) => TryUse(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; + } + + public override void OnAfterDelete() + { + _ownerMobile = null; + base.OnAfterDelete(); + } + + public static int GetCureCost(int poisonLevel) => Math.Min(120, Math.Max(0, poisonLevel * 25)); + + public static double GetCureChance(double mysticism, double supportSkill, int poisonLevel) + { + var effectiveSkill = (mysticism + supportSkill) / 2.0; + return (10000 + effectiveSkill * 75 - (poisonLevel + 1) * 2000) / 100.0; + } + + internal static void OnPotionHealed(Mobile from) + { + var stone = from?.Backpack?.FindItemByType(); + stone?.ResetHealingAfterPotion(); + } + + private bool TryUse(Mobile from) + { + if (Deleted || from == null || from.Deleted) + { + return false; + } + + if (from != _ownerMobile) + { + from.SendMessage("Only the mystic who created this stone can use it."); + return false; + } + + if (!IsChildOf(from.Backpack)) + { + from.SendLocalizedMessage(1042001); // That must be in your pack for you to use it. + return false; + } + + if (!from.InRange(GetWorldLocation(), 1)) + { + from.SendLocalizedMessage(502138); // That is too far away for you to use. + return false; + } + + if (!BasePotion.HasFreeHand(from)) + { + from.SendLocalizedMessage(1080116); // You must have a free hand to use a Healing Stone. + return false; + } + + if (!from.Poisoned && from.Hits >= from.HitsMax) + { + from.SendLocalizedMessage(1049547); // You are already at full health. + return false; + } + + if (MortalStrike.IsWounded(from)) + { + from.LocalOverheadMessage(MessageType.Regular, 0x22, 1005000); // You can not heal yourself in your current state. + return false; + } + + if (!from.CanBeginAction()) + { + from.SendLocalizedMessage(1095172); // You must wait a few seconds before using another Healing Stone. + return false; + } + + from.BeginAction(); + Timer.StartTimer(TimeSpan.FromSeconds(UseCooldownSeconds), from.EndAction); + + if (from.Poisoned) + { + TryCure(from); + return true; + } + + Replenish(); + + var toHeal = Math.Min(_healingAvailable, Math.Min(_storedLifeForce, from.HitsMax - from.Hits)); + if (toHeal <= 0) + { + from.SendLocalizedMessage(1115264); // Your healing stone does not have enough energy to remove the poison. + return true; + } + + var oldHits = from.Hits; + SpellHelper.Heal(toHeal, from, _ownerMobile); + var healed = Math.Max(0, from.Hits - oldHits); + + _storedLifeForce -= healed; + _healingAvailable = Math.Min(1, _maximumHealing); + _rechargeStarted = Core.Now; + InvalidateProperties(); + + from.FixedParticles(0x376A, 9, 32, 5030, EffectLayer.Waist); + from.PlaySound(0x202); + + if (_storedLifeForce <= 0) + { + from.SendLocalizedMessage(1115266); // The healing stone has used up all its energy and has been destroyed. + Delete(); + } + + return true; + } + + private void TryCure(Mobile from) + { + var poisonLevel = Poison.IncreaseLevel(from.Poison).Level; + var cost = GetCureCost(poisonLevel); + + if (_maximumLifeForce < cost) + { + from.SendLocalizedMessage(1115265); // Your Mysticism, Focus, or Imbuing Skills are not enough to use the heal stone to cure yourself. + return; + } + + if (_storedLifeForce < cost) + { + from.SendLocalizedMessage(1115264); // Your healing stone does not have enough energy to remove the poison. + LifeForce -= cost / 3; + return; + } + + var chanceToCure = GetCureChance( + MysticSpell.GetBaseSkill(from), + Math.Max(from.Skills.Focus.Value, from.Skills.Imbuing.Value), + poisonLevel + ); + + if (chanceToCure > Utility.Random(100) && from.CurePoison(_ownerMobile)) + { + from.SendLocalizedMessage(500231); // You feel cured of poison! + from.FixedEffect(0x373A, 10, 15); + from.PlaySound(0x1E0); + LifeForce -= cost; + } + else + { + from.SendMessage("The Healing Stone failed to cure your poison."); + LifeForce -= cost / 3; + } + } + + private void ResetHealingAfterPotion() + { + _healingAvailable = Math.Min(1, _maximumHealing); + _rechargeStarted = Core.Now; + InvalidateProperties(); + } + + private void Replenish() + { + if (_healingAvailable >= _maximumHealing) + { + return; + } + + var elapsed = Core.Now - _rechargeStarted; + if (elapsed <= TimeSpan.Zero) + { + return; + } + + var recovered = (int)(elapsed.TotalSeconds * _maximumHealing / FullRechargeSeconds); + if (recovered <= 0) + { + return; + } + + _healingAvailable = Math.Min(_maximumHealing, _healingAvailable + recovered); + _rechargeStarted = _healingAvailable >= _maximumHealing + ? Core.Now + : _rechargeStarted.AddSeconds(recovered * (double)FullRechargeSeconds / _maximumHealing); + InvalidateProperties(); + } +} diff --git a/Projects/UOContent/Items/Skill Items/Magical/Potions/Heal Potions/BaseHealPotion.cs b/Projects/UOContent/Items/Skill Items/Magical/Potions/Heal Potions/BaseHealPotion.cs index 915f6f04a..15e9d0de7 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Potions/Heal Potions/BaseHealPotion.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Potions/Heal Potions/BaseHealPotion.cs @@ -19,7 +19,9 @@ public abstract partial class BaseHealPotion : BasePotion var min = Scale(from, MinHeal); var max = Scale(from, MaxHeal); - from.Heal(Utility.RandomMinMax(min, max)); + var healed = Utility.RandomMinMax(min, max); + from.Heal(healed); + HealingStone.OnPotionHealed(from); } public override bool CanDrink(Mobile from) diff --git a/Projects/UOContent/Migrations/Server.Items.HealingStone.v0.json b/Projects/UOContent/Migrations/Server.Items.HealingStone.v0.json new file mode 100644 index 000000000..18e83c1ce --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.HealingStone.v0.json @@ -0,0 +1,51 @@ +{ + "version": 0, + "type": "Server.Items.HealingStone", + "properties": [ + { + "name": "OwnerMobile", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "StoredLifeForce", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MaximumLifeForce", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HealingAvailable", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MaximumHealing", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RechargeStarted", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Spells/Initializer.cs b/Projects/UOContent/Spells/Initializer.cs index 28d068080..a50ce6ef5 100644 --- a/Projects/UOContent/Spells/Initializer.cs +++ b/Projects/UOContent/Spells/Initializer.cs @@ -182,7 +182,7 @@ namespace Server.Spells { // Mysticism spells Register(677, typeof(NetherBoltSpell)); - // Register(678, typeof(HealingStoneSpell)); + Register(678, typeof(HealingStoneSpell)); Register(679, typeof(PurgeMagicSpell)); // Register(680, typeof(EnchantSpell)); // Register(681, typeof(SleepSpell)); diff --git a/Projects/UOContent/Spells/Mysticism/HealingStoneSpell.cs b/Projects/UOContent/Spells/Mysticism/HealingStoneSpell.cs new file mode 100644 index 000000000..780e3230d --- /dev/null +++ b/Projects/UOContent/Spells/Mysticism/HealingStoneSpell.cs @@ -0,0 +1,62 @@ +using System; +using Server.Items; + +namespace Server.Spells.Mysticism; + +public class HealingStoneSpell : MysticSpell +{ + private static readonly SpellInfo _info = new( + "Healing Stone", + "Kal In Mani", + -1, + 9002, + Reagent.Bone, + Reagent.Garlic, + Reagent.Ginseng, + Reagent.SpidersSilk + ); + + public HealingStoneSpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info) + { + } + + public override SpellCircle Circle => SpellCircle.First; + + public override TimeSpan CastDelayBase => TimeSpan.FromSeconds(5.0); + + public override bool CheckCast() + { + if (!base.CheckCast()) + { + return false; + } + + if (Caster.Backpack == null) + { + Caster.SendLocalizedMessage(1042001); // That must be in your pack for you to use it. + return false; + } + + return true; + } + + public override void OnCast() + { + if (CheckSequence()) + { + Caster.Backpack.FindItemByType()?.Delete(); + + var totalSkill = GetBaseSkill(Caster) + GetDamageSkill(Caster); + var lifeForce = Math.Max(1, (int)(totalSkill * 1.25)); + var maxHealing = Math.Max(1, (int)(totalSkill / 6.0)); + var stone = new HealingStone(Caster, lifeForce, maxHealing); + + Caster.AddToBackpack(stone); + Caster.SendLocalizedMessage(1080115); // A Healing Stone appears in your backpack. + Caster.FixedParticles(0x3779, 1, 30, 0x26B8, 0, 0, EffectLayer.Waist); + Caster.PlaySound(0x650); + } + + FinishSequence(); + } +}