feat: implement Wildfire spell

This commit is contained in:
Crome696 2026-07-10 17:57:37 +02:00
parent a969ac0d45
commit 57774b1f38
3 changed files with 575 additions and 1 deletions

View file

@ -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<MagicArrowSpell>(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<WildfireSpell>(SpellRegistry.NewSpell(609, caster, null));
Assert.IsType<EssenceOfWindSpell>(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<WildfireFireItem>();
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<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 TestOrc : Orc
{
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
{
activeSpeed = 0.2;
passiveSpeed = 0.4;
}
}
}

View file

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

View file

@ -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<IPoint3D>
{
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<Mobile, long> _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<IPoint3D>(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<Mobile>.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<Mobile>.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();
}
}