feat: implement Healing Stone spell

This commit is contained in:
Crome696 2026-07-10 00:36:06 +02:00
parent fa3b9024cb
commit b048bc9813
6 changed files with 726 additions and 2 deletions

View file

@ -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<HealingStoneSpell>(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<HealingStone>();
Assert.NotNull(stone);
Assert.Same(caster, stone.Owner);
Assert.Equal(96, caster.Mana);
Assert.Equal(9, caster.Backpack.FindItemByType<Bone>().Amount);
Assert.Equal(9, caster.Backpack.FindItemByType<Garlic>().Amount);
Assert.Equal(9, caster.Backpack.FindItemByType<Ginseng>().Amount);
Assert.Equal(9, caster.Backpack.FindItemByType<SpidersSilk>().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<HealingStone>();
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<HealingStone>();
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<HealingStone>();
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<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 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;
}
}

View file

@ -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<HealingStone>();
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<HealingStone>())
{
from.SendLocalizedMessage(1095172); // You must wait a few seconds before using another Healing Stone.
return false;
}
from.BeginAction<HealingStone>();
Timer.StartTimer(TimeSpan.FromSeconds(UseCooldownSeconds), from.EndAction<HealingStone>);
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();
}
}

View file

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

View file

@ -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": [
""
]
}
]
}

View file

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

View file

@ -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<HealingStone>()?.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();
}
}