From 57774b1f38be65bbeaf95d4aac21fe1931a283aa Mon Sep 17 00:00:00 2001 From: Crome696 Date: Fri, 10 Jul 2026 17:57:37 +0200 Subject: [PATCH] feat: implement Wildfire spell --- .../Spells/Spellweaving/WildfireSpellTests.cs | 285 +++++++++++++++++ Projects/UOContent/Spells/Initializer.cs | 2 +- .../UOContent/Spells/Spellweaving/Wildfire.cs | 289 ++++++++++++++++++ 3 files changed, 575 insertions(+), 1 deletion(-) create mode 100644 Projects/UOContent.Tests/Tests/Spells/Spellweaving/WildfireSpellTests.cs create mode 100644 Projects/UOContent/Spells/Spellweaving/Wildfire.cs diff --git a/Projects/UOContent.Tests/Tests/Spells/Spellweaving/WildfireSpellTests.cs b/Projects/UOContent.Tests/Tests/Spells/Spellweaving/WildfireSpellTests.cs new file mode 100644 index 000000000..b2ed74a27 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Spells/Spellweaving/WildfireSpellTests.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Server.Items; +using Server.Mobiles; +using Server.Spells; +using Server.Spells.First; +using Server.Spells.Spellweaving; +using Xunit; + +namespace Server.Tests.Spells.Spellweaving; + +[Collection("Sequential UOContent Tests")] +public class WildfireSpellTests +{ + [Fact] + public void SpellMetadata_MatchesWildfireSources() + { + var caster = NewCaster(); + var spell = new WildfireSpell(caster); + + Assert.Equal("Wildfire", spell.Name); + Assert.Equal("Haelyn", spell.Mantra); + Assert.Equal(TimeSpan.FromSeconds(2.5), spell.CastDelayBase); + Assert.Equal(66.0, spell.RequiredSkill); + Assert.Equal(50, spell.GetMana()); + Assert.Equal(SkillName.Spellweaving, spell.CastSkill); + Assert.Equal(SkillName.Spellweaving, spell.DamageSkill); + Assert.False(spell.ClearHandsOnCast); + + caster.Delete(); + } + + [Fact] + public void SpellweavingBookAndScroll_UseWildfireSlot() + { + var book = new SpellweavingBook(1UL << (609 - 600)); + var scroll = new WildfireScroll(); + + Assert.Equal(600, book.BookOffset); + Assert.Equal(16, book.BookCount); + Assert.True(book.HasSpell(609)); + Assert.Equal(609, GetSpellScrollId(scroll)); + + book.Delete(); + scroll.Delete(); + } + + [Fact] + public void RegisterSpellweaving_PreMlDoesNotExposeWildfire() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.SE; + Initializer.Configure(); + + Assert.Null(SpellRegistry.NewSpell(609, caster, null)); + Assert.Equal(-1, SpellRegistry.GetRegistryNumber(typeof(WildfireSpell))); + Assert.IsType(SpellRegistry.NewSpell(4, caster, null)); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void RegisterSpellweaving_MlExposesWildfireAtSpellId609() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.ML; + Initializer.Configure(); + + Assert.Same(typeof(WildfireSpell), SpellRegistry.Types[609]); + Assert.Equal(609, SpellRegistry.GetRegistryNumber(typeof(WildfireSpell))); + Assert.IsType(SpellRegistry.NewSpell(609, caster, null)); + Assert.IsType(SpellRegistry.NewSpell(610, caster, null)); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void CheckCast_UsesSkillAndManaRequirements() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(65.9); + + try + { + Core.Expansion = Expansion.ML; + var spell = new WildfireSpell(caster); + + Assert.False(spell.CheckCast()); + + caster.Skills.Spellweaving.Base = 66.0; + caster.Mana = 0; + Assert.False(spell.CheckCast()); + + caster.Mana = caster.ManaMax; + Assert.True(spell.CheckCast()); + } + finally + { + Core.Expansion = previousExpansion; + caster.Delete(); + } + } + + [Theory] + [InlineData(0.0, 0, 10, 1, 5)] + [InlineData(66.0, 0, 12, 2, 5)] + [InlineData(120.0, 0, 15, 5, 5)] + [InlineData(66.0, 3, 15, 5, 8)] + public void FocusAndSkillScaleBaseDamageDurationAndRadius( + double skill, + int focus, + int expectedDamage, + int expectedDurationSeconds, + int expectedRadius + ) + { + Assert.Equal(expectedDamage, WildfireSpell.GetBaseDamage(skill, focus)); + Assert.Equal(TimeSpan.FromSeconds(expectedDurationSeconds), WildfireSpell.GetDuration(skill, focus)); + Assert.Equal(expectedRadius, WildfireSpell.GetRadius(focus)); + } + + [Theory] + [InlineData(15, 1, 15)] + [InlineData(15, 2, 7)] + [InlineData(15, 3, 5)] + [InlineData(11, 3, 5)] + public void DamageSplitsAcrossMultipleTargets(int baseDamage, int targetCount, int expectedDamage) + { + Assert.Equal(expectedDamage, WildfireSpell.GetDamageForTargetCount(baseDamage, targetCount)); + } + + [Fact] + public void SpellDamageIncreaseIsAppliedAfterSplitAndCappedInPlayerCombat() + { + Assert.Equal(11, WildfireSpell.GetDamageAfterSdi(10, 100, true)); + Assert.Equal(20, WildfireSpell.GetDamageAfterSdi(10, 100, false)); + Assert.Equal(8, WildfireSpell.GetDamageAfterSdi(7, 15, true)); + } + + [Fact] + public void TickDamagesVisibleHostileTargetsButNotHiddenTargets() + { + var caster = NewCaster(); + var target = new TestOrc(); + var location = new Point3D(100, 100, 0); + var spell = new WildfireSpell(caster); + + try + { + caster.MoveToWorld(location, Map.Felucca); + target.MoveToWorld(new Point3D(101, 100, 0), Map.Felucca); + target.Hits = target.HitsMax; + + WildfireSpell.ClearAllForTests(); + spell.StartTimerForTests(location, Map.Felucca, 5, 10, TimeSpan.FromSeconds(1)); + var visibleHits = target.Hits; + spell.TickForTests(); + + Assert.True(target.Hits < visibleHits); + + WildfireSpell.ClearAllForTests(); + target.Hits = visibleHits; + target.Hidden = true; + spell.TickForTests(); + + Assert.Equal(visibleHits, target.Hits); + } + finally + { + WildfireSpell.ClearAllForTests(); + DeleteFireItems(target.Location); + target.Delete(); + caster.Delete(); + } + } + + [Fact] + public void FireItemsStopTheirTimerOnDeletionAndDoNotSurviveDeserialization() + { + var item = new WildfireFireItem(new Point3D(100, 100, 0), Map.Felucca, TimeSpan.FromMinutes(1)); + var timerField = typeof(WildfireFireItem).GetField("_timer", BindingFlags.Instance | BindingFlags.NonPublic); + var timer = timerField!.GetValue(item); + + item.Delete(); + + Assert.Null(timerField.GetValue(item)); + Assert.NotNull(timer); + + var deserializedItem = new WildfireFireItem(new Point3D(100, 100, 0), Map.Felucca, TimeSpan.FromMinutes(1)); + var afterDeserialization = typeof(WildfireFireItem).GetMethod( + "AfterDeserialization", + BindingFlags.Instance | BindingFlags.NonPublic + ); + + afterDeserialization!.Invoke(deserializedItem, null); + + Assert.True(deserializedItem.Deleted); + DeleteFireItems(new Point3D(100, 100, 0)); + } + + private static Mobile NewCaster(double spellweaving = 120.0) + { + var caster = new Mobile(World.NewMobile); + caster.DefaultMobileInit(); + caster.InitStats(100, 100, 100); + caster.Mana = caster.ManaMax; + caster.Skills.Spellweaving.Base = spellweaving; + return caster; + } + + private static void DeleteFireItems(Point3D location) + { + var items = new List(); + + foreach (var item in Map.Felucca.GetItemsInRange(location, 2)) + { + if (item is WildfireFireItem) + { + items.Add((WildfireFireItem)item); + } + } + + foreach (var item in items) + { + item.Delete(); + } + } + + 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 TestOrc : Orc + { + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.2; + passiveSpeed = 0.4; + } + } +} diff --git a/Projects/UOContent/Spells/Initializer.cs b/Projects/UOContent/Spells/Initializer.cs index fe495536d..45e32bd66 100644 --- a/Projects/UOContent/Spells/Initializer.cs +++ b/Projects/UOContent/Spells/Initializer.cs @@ -169,7 +169,7 @@ namespace Server.Spells Register(606, typeof(SummonFeySpell)); Register(607, typeof(SummonFiendSpell)); Register(608, typeof(ReaperFormSpell)); - // Register(609, typeof(WildfireSpell)); + Register(609, typeof(WildfireSpell)); Register(610, typeof(EssenceOfWindSpell)); Register(611, typeof(DryadAllureSpell)); Register(612, typeof(EtherealVoyageSpell)); diff --git a/Projects/UOContent/Spells/Spellweaving/Wildfire.cs b/Projects/UOContent/Spells/Spellweaving/Wildfire.cs new file mode 100644 index 000000000..52441b981 --- /dev/null +++ b/Projects/UOContent/Spells/Spellweaving/Wildfire.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using ModernUO.CodeGeneratedEvents; +using ModernUO.Serialization; +using Server.Collections; +using Server.Items; +using Server.Mobiles; +using Server.Misc; + +namespace Server.Spells.Spellweaving; + +public class WildfireSpell : ArcanistSpell, ITargetingSpell +{ + private const int BaseRadius = 5; + private const int MaxBaseDamage = 15; + private const int TargetCooldownMilliseconds = 1000; + + private static readonly SpellInfo _info = new("Wildfire", "Haelyn", -1); + private static readonly Dictionary _targetCooldowns = new(); + private WildfireTimer _timer; + + public WildfireSpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info) + { + } + + public override TimeSpan CastDelayBase => TimeSpan.FromSeconds(2.5); + + public int TargetRange => Core.T2A ? 10 : 12; + + public override double RequiredSkill => 66.0; + + public override int RequiredMana => 50; + + public override void OnCast() + { + Caster.Target = new SpellTarget(this, allowGround: true); + } + + public void Target(IPoint3D p) + { + if (!TryGetTargetLocation(p, out var location, out var map)) + { + return; + } + + if (!CheckSequence()) + { + return; + } + + SpellHelper.Turn(Caster, location); + + var focusLevel = FocusLevel; + var radius = GetRadius(focusLevel); + var duration = GetDuration(Caster.Skills.Spellweaving.Value, focusLevel); + var damage = GetBaseDamage(Caster.Skills.Spellweaving.Value, focusLevel); + + PlaceFieldVisuals(location, map, radius, duration); + Effects.PlaySound(location, map, 0x5CF); + _timer = new WildfireTimer(this, location, map, radius, damage, duration); + _timer.Start(); + } + + internal static int GetBaseDamage(double spellweaving, int focusLevel) => + Math.Min(MaxBaseDamage, 10 + (int)(spellweaving / 24) + focusLevel); + + internal static TimeSpan GetDuration(double spellweaving, int focusLevel) => + TimeSpan.FromSeconds(Math.Min(5, Math.Max(1, (int)(spellweaving / 24))) + focusLevel); + + internal static int GetRadius(int focusLevel) => BaseRadius + focusLevel; + + internal static int GetDamageForTargetCount(int baseDamage, int targetCount) => + targetCount switch + { + <= 1 => baseDamage, + 2 => Math.Min(10, baseDamage / 2), + _ => Math.Max(5, baseDamage / 3) + }; + + internal static int GetDamageAfterSdi(int damage, int spellDamageIncrease, bool playerVsPlayer) + { + if (playerVsPlayer) + { + spellDamageIncrease = Math.Min(spellDamageIncrease, 15); + } + + return damage * (100 + spellDamageIncrease) / 100; + } + + internal static bool IsValidTarget(Mobile caster, Mobile target) => + target is { Deleted: false, Alive: true, Hidden: false } && + caster != target && + caster.Map == target.Map && + caster.CanSee(target) && + caster.InLOS(target) && + SpellHelper.ValidIndirectTarget(caster, target) && + caster.CanBeHarmful(target, false); + + internal static void ClearTargetCooldown(Mobile target) + { + if (target != null) + { + _targetCooldowns.Remove(target); + } + } + + internal static void ClearAllForTests() + { + _targetCooldowns.Clear(); + } + + internal void StartTimerForTests(Point3D location, Map map, int radius, int damage, TimeSpan duration) + { + _timer = new WildfireTimer(this, location, map, radius, damage, duration); + } + + internal void TickForTests() => _timer?.TickForTests(); + + private bool TryGetTargetLocation(IPoint3D target, out Point3D location, out Map map) + { + location = default; + map = Caster.Map; + + if (target == null || map == null || map == Map.Internal) + { + return false; + } + + var targetPoint = target; + SpellHelper.GetSurfaceTop(ref targetPoint); + location = new Point3D(targetPoint); + + if (!Caster.InRange(location, TargetRange) || !Caster.CanSee(location) || !Caster.InLOS(location)) + { + Caster.SendLocalizedMessage(500237); // Target can not be seen. + return false; + } + + if (!SpellHelper.CheckTown(location, Caster) || !SpellHelper.AdjustField(ref location, map, 12, false)) + { + return false; + } + + return true; + } + + private static void PlaceFieldVisuals(Point3D location, Map map, int radius, TimeSpan duration) + { + for (var x = -1; x <= 1; x++) + { + for (var y = -1; y <= 1; y++) + { + if (x == 0 && y == 0) + { + continue; + } + + var visualLocation = new Point3D(location.X + x * radius, location.Y + y * radius, location.Z); + + if (SpellHelper.AdjustField(ref visualLocation, map, 12, false)) + { + new WildfireFireItem(visualLocation, map, duration); + } + } + } + } + + private static void DefragTargetCooldowns() + { + using var expired = PooledRefList.Create(); + + foreach (var (target, expiresAt) in _targetCooldowns) + { + if (target.Deleted || Core.TickCount >= expiresAt) + { + expired.Add(target); + } + } + + for (var i = 0; i < expired.Count; i++) + { + _targetCooldowns.Remove(expired[i]); + } + } + + [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] + [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] + [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] + [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] + public static void OnTargetRemoved(Mobile target) => ClearTargetCooldown(target); + + private sealed class WildfireTimer : Timer + { + private readonly WildfireSpell _spell; + private readonly Mobile _caster; + private readonly Point3D _location; + private readonly Map _map; + private readonly int _radius; + private readonly int _baseDamage; + + public WildfireTimer( + WildfireSpell spell, + Point3D location, + Map map, + int radius, + int baseDamage, + TimeSpan duration + ) : base(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1), (int)duration.TotalSeconds) + { + _spell = spell; + _caster = spell.Caster; + _location = location; + _map = map; + _radius = radius; + _baseDamage = baseDamage; + } + + protected override void OnTick() + { + if (_caster.Deleted || _map == null || _map == Map.Internal) + { + Stop(); + return; + } + + DefragTargetCooldowns(); + + using var targets = PooledRefQueue.Create(); + + foreach (var target in _map.GetMobilesInRange(_location, _radius)) + { + if (!_targetCooldowns.ContainsKey(target) && IsValidTarget(_caster, target)) + { + targets.Enqueue(target); + } + } + + var targetCount = targets.Count; + + while (targets.Count > 0) + { + var target = targets.Dequeue(); + _caster.DoHarmful(target); + + var damage = GetDamageForTargetCount(_baseDamage, targetCount); + var playerVsPlayer = _caster.Player && target.Player; + damage = GetDamageAfterSdi( + damage, + AosAttributes.GetValue(_caster, AosAttribute.SpellDamage), + playerVsPlayer + ); + + SpellHelper.Damage(_spell, target, damage, 0, 100, 0, 0, 0); + new WildfireFireItem(target.Location, _map, TimeSpan.FromSeconds(1)); + _targetCooldowns[target] = Core.TickCount + TargetCooldownMilliseconds; + } + } + + internal void TickForTests() => OnTick(); + } +} + +[DispellableField] +[SerializationGenerator(0, false)] +public partial class WildfireFireItem : Item +{ + private Timer _timer; + + public WildfireFireItem(Point3D location, Map map, TimeSpan duration) + : base(Utility.RandomBool() ? 0x398C : 0x3996) + { + Movable = false; + MoveToWorld(location, map); + _timer = Timer.DelayCall(duration, Delete); + } + + public override void OnAfterDelete() + { + _timer?.Stop(); + _timer = null; + base.OnAfterDelete(); + } + + [AfterDeserialization] + private void AfterDeserialization() + { + Delete(); + } +}