From a44cfbd5c9c2cfeddb23d2bfcb544b4da526d452 Mon Sep 17 00:00:00 2001 From: Crome696 Date: Wed, 8 Jul 2026 23:43:57 +0200 Subject: [PATCH] feat(items): add Sparks weapon property --- .../Weapons/ExtendedWeaponAttributesTests.cs | 5 + .../Items/Weapons/SparksPropertyTests.cs | 500 ++++++++++++++++++ .../UOContent/Items/Weapons/BaseWeapon.cs | 5 + Projects/UOContent/Items/Weapons/Sparks.cs | 197 +++++++ Projects/UOContent/Misc/AOS.cs | 15 +- 5 files changed, 721 insertions(+), 1 deletion(-) create mode 100644 Projects/UOContent.Tests/Tests/Items/Weapons/SparksPropertyTests.cs create mode 100644 Projects/UOContent/Items/Weapons/Sparks.cs diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs index 829e68995..75420971c 100644 --- a/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs @@ -24,6 +24,7 @@ public class ExtendedWeaponAttributesTests { Assert.Equal(0x00000001, (int)ExtendedWeaponAttribute.Bane); Assert.Equal(0x00000002, (int)ExtendedWeaponAttribute.BattleLust); + Assert.Equal(0x00000004, (int)ExtendedWeaponAttribute.HitSparks); } [Fact] @@ -37,6 +38,7 @@ public class ExtendedWeaponAttributesTests Assert.True(weapon.ExtendedWeaponAttributes.IsEmpty); Assert.Equal(0, weapon.ExtendedWeaponAttributes.Bane); Assert.Equal(0, weapon.ExtendedWeaponAttributes.BattleLust); + Assert.Equal(0, weapon.ExtendedWeaponAttributes.HitSparks); } finally { @@ -70,6 +72,7 @@ public class ExtendedWeaponAttributesTests AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.Bane)); AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.BattleLust)); + AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.HitSparks)); } [Fact] @@ -84,6 +87,7 @@ public class ExtendedWeaponAttributesTests Core.Expansion = Expansion.HS; weapon.ExtendedWeaponAttributes.Bane = 1; weapon.ExtendedWeaponAttributes.BattleLust = 1; + weapon.ExtendedWeaponAttributes.HitSparks = 20; var writer = new BufferWriter(true); weapon.Serialize(writer); @@ -96,6 +100,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(buffer.Length, reader.Position); Assert.Equal(1, deserialized.ExtendedWeaponAttributes.Bane); Assert.Equal(1, deserialized.ExtendedWeaponAttributes.BattleLust); + Assert.Equal(20, deserialized.ExtendedWeaponAttributes.HitSparks); } finally { diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/SparksPropertyTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/SparksPropertyTests.cs new file mode 100644 index 000000000..9023018dd --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/SparksPropertyTests.cs @@ -0,0 +1,500 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Server; +using Server.Items; +using Server.Mobiles; +using Server.Spells; +using Server.Spells.Bushido; +using Server.Spells.Ninjitsu; +using Server.Text; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class SparksPropertyTests +{ + private const int SparksCliloc = 1157326; + private static readonly Point3D TestLocation = new(6200, 500, 0); + + [Fact] + public void ExtendedWeaponAttributes_StoresAndDupesSparks() + { + var weapon = new TestKatana(); + var dupe = new TestKatana(); + + try + { + weapon.ExtendedWeaponAttributes.HitSparks = 20; + + weapon.Dupe(dupe); + + Assert.Equal(20, weapon.ExtendedWeaponAttributes.HitSparks); + Assert.Equal(20, dupe.ExtendedWeaponAttributes.HitSparks); + } + finally + { + weapon.Delete(); + dupe.Delete(); + } + } + + [Fact] + public void ExtendedWeaponAttributes_GetProperties_GatesSparksTooltipToTimeOfLegends() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + + try + { + weapon.ExtendedWeaponAttributes.HitSparks = 20; + + Core.Expansion = Expansion.HS; + var preTimeOfLegends = new RecordingPropertyList(); + weapon.ExtendedWeaponAttributes.GetProperties(preTimeOfLegends); + Assert.DoesNotContain(preTimeOfLegends.Entries, entry => entry.Number == SparksCliloc); + + Core.Expansion = Expansion.TOL; + var timeOfLegends = new RecordingPropertyList(); + weapon.ExtendedWeaponAttributes.GetProperties(timeOfLegends); + Assert.Contains( + timeOfLegends.Entries, + entry => entry.Number == SparksCliloc && entry.Argument == "20" + ); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + } + } + + [Fact] + public void OnHit_PreTimeOfLegends_DoesNotStartSparksContext() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.HS; + weapon.ExtendedWeaponAttributes.HitSparks = 100; + + weapon.OnHit(attacker, defender); + + Assert.False(Sparks.HasActiveContext(attacker, defender)); + Assert.Equal(0, Sparks.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + Sparks.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void OnHit_NormalSuccessfulHit_StartsOneSparksContextAndRejectsDuplicateProc() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.TOL; + weapon.ExtendedWeaponAttributes.HitSparks = 100; + + weapon.OnHit(attacker, defender); + + Assert.True(Sparks.HasActiveContext(attacker, defender)); + Assert.Equal(1, Sparks.ActiveContextCount); + Assert.Equal(Sparks.TickCount, Sparks.GetTicksRemainingForTests(attacker, defender)); + + weapon.OnHit(attacker, defender); + + Assert.True(Sparks.HasActiveContext(attacker, defender)); + Assert.Equal(1, Sparks.ActiveContextCount); + Assert.Equal(Sparks.TickCount, Sparks.GetTicksRemainingForTests(attacker, defender)); + } + finally + { + Core.Expansion = previousExpansion; + Sparks.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void OnHit_CurrentWeaponAbility_DoesNotStartSparksContext() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.TOL; + weapon.ExtendedWeaponAttributes.HitSparks = 100; + WeaponAbility.Table[attacker] = new TestWeaponAbility(); + + weapon.OnHit(attacker, defender); + + Assert.False(Sparks.HasActiveContext(attacker, defender)); + } + finally + { + Core.Expansion = previousExpansion; + WeaponAbility.Table.Remove(attacker); + Sparks.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void OnHit_CurrentGenericSpecialMove_DoesNotStartSparksContext() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.TOL; + weapon.ExtendedWeaponAttributes.HitSparks = 100; + SpecialMove.Table[attacker] = new TestSpecialMove(); + + weapon.OnHit(attacker, defender); + + Assert.False(Sparks.HasActiveContext(attacker, defender)); + } + finally + { + Core.Expansion = previousExpansion; + SpecialMove.Table.Remove(attacker); + Sparks.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Theory] + [InlineData(typeof(LightningStrike))] + [InlineData(typeof(DeathStrike))] + public void OnHit_NamedSpecialMoveExclusions_DoNotStartSparksContext(Type moveType) + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.TOL; + weapon.ExtendedWeaponAttributes.HitSparks = 100; + SpecialMove.Table[attacker] = (SpecialMove)Activator.CreateInstance(moveType); + + weapon.OnHit(attacker, defender); + + Assert.False(Sparks.HasActiveContext(attacker, defender)); + } + finally + { + Core.Expansion = previousExpansion; + SpecialMove.Table.Remove(attacker); + Sparks.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void ApplySparksTick_PlayerTarget_UsesEnergyResistanceAndReturnsAppliedDamageAsMana() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(mana: 10); + var defender = CreateMobile(hitsMax: 200, hits: 200, player: true, location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.TOL; + AddResistance(defender, ResistanceType.Energy, 70); + + var damageApplied = Sparks.ApplySparksTick(attacker, defender, 20); + + Assert.Equal(6, damageApplied); + Assert.Equal(194, defender.Hits); + Assert.Equal(16, attacker.Mana); + } + finally + { + Core.Expansion = previousExpansion; + Sparks.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void ApplySparksTick_NonPlayerTarget_DoublesRawDamageBeforeResistance() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(mana: 10); + var defender = CreateMobile(hitsMax: 200, hits: 200, location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.TOL; + AddResistance(defender, ResistanceType.Energy, 70); + + var damageApplied = Sparks.ApplySparksTick(attacker, defender, 20); + + Assert.Equal(12, damageApplied); + Assert.Equal(188, defender.Hits); + Assert.Equal(22, attacker.Mana); + } + finally + { + Core.Expansion = previousExpansion; + Sparks.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void ApplySparksTick_ManaReturnIsClampedByManaMax() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(mana: 95); + var defender = CreateMobile(hitsMax: 200, hits: 200, player: true, location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.TOL; + + var damageApplied = Sparks.ApplySparksTick(attacker, defender, 20); + + Assert.Equal(20, damageApplied); + Assert.Equal(180, defender.Hits); + Assert.Equal(attacker.ManaMax, attacker.Mana); + } + finally + { + Core.Expansion = previousExpansion; + Sparks.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void SparksContext_TicksFiveTimesAtOneSecondIntervalAndThenCleansUp() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(mana: 0); + var defender = CreateMobile(hitsMax: 500, hits: 500, player: true, location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.TOL; + Sparks.RawDamageOverrideForTests = 20; + + Sparks.TryProcOnNormalHit(attacker, defender, 100); + + Assert.True(Sparks.HasActiveContext(attacker, defender)); + Assert.Equal(TimeSpan.FromSeconds(1.0), Sparks.TickInterval); + + for (var i = 0; i < Sparks.TickCount; i++) + { + Assert.True(Sparks.TickForTests(attacker, defender)); + } + + Assert.False(Sparks.HasActiveContext(attacker, defender)); + Assert.Equal(400, defender.Hits); + Assert.Equal(100, attacker.Mana); + } + finally + { + Core.Expansion = previousExpansion; + Sparks.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void SparksContext_TickCleansUpInternalizedCombatant() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.TOL; + + Sparks.TryProcOnNormalHit(attacker, defender, 100); + defender.Internalize(); + + Assert.True(Sparks.TickForTests(attacker, defender)); + Assert.False(Sparks.HasActiveContext(attacker, defender)); + Assert.Equal(0, Sparks.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + Sparks.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void SparksClear_RemovesContextsForDeletedOrDeadEventMobile() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.TOL; + + Sparks.TryProcOnNormalHit(attacker, defender, 100); + Sparks.Clear(defender); + + Assert.False(Sparks.HasActiveContext(attacker, defender)); + Assert.Equal(0, Sparks.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + Sparks.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void RunicAttributeGeneration_DoesNotRollSparks() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + + try + { + Core.Expansion = Expansion.TOL; + + BaseRunicTool.ApplyAttributesTo(weapon, false, 0, 25, 100, 100); + + Assert.Equal(0, weapon.ExtendedWeaponAttributes.HitSparks); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + } + } + + private static Mobile CreateMobile( + int hitsMax = 200, + int hits = 200, + int manaMax = 100, + int mana = 0, + bool player = false, + Point3D location = default + ) + { + var mobile = new Mobile(World.NewMobile) + { + Player = player + }; + + mobile.DefaultMobileInit(); + mobile.RawStr = Math.Max(1, (hitsMax - 50) * 2); + mobile.RawInt = manaMax; + Assert.Equal(hitsMax, mobile.HitsMax); + Assert.Equal(manaMax, mobile.ManaMax); + mobile.Hits = hits; + mobile.Mana = mana; + mobile.MoveToWorld(location == default ? TestLocation : location, Map.Felucca); + return mobile; + } + + private static void AddResistance(Mobile mobile, ResistanceType type, int offset) => + mobile.AddResistanceMod(new ResistanceMod(type, $"SparksTest{type}{mobile.Serial}", offset, mobile)); + + private class TestKatana : Katana + { + public override bool CheckHit(Mobile attacker, Mobile defender) => true; + public override int ComputeDamage(Mobile attacker, Mobile defender) => 1; + public override int AbsorbDamage(Mobile attacker, Mobile defender, int damage) => damage; + public override void AddBlood(Mobile attacker, Mobile defender, int damage) + { + } + } + + private class TestWeaponAbility : WeaponAbility + { + } + + private class TestSpecialMove : SpecialMove + { + } + + private sealed class RecordingPropertyList : IPropertyList + { + private string _interpolated = string.Empty; + + public List<(int Number, string Argument)> Entries { get; } = []; + + public void Reset() + { + } + + public void Terminate() + { + } + + public void Add(int number) => Entries.Add((number, null)); + public void Add(int number, string argument) => Entries.Add((number, argument)); + public void Add(ReadOnlySpan argument) => Entries.Add((0, argument.ToString())); + public void Add(int number, ReadOnlySpan argument) => Entries.Add((number, argument.ToString())); + public void AddChunked(ReadOnlySpan text) => Entries.Add((0, text.ToString())); + public OplTextBlock TextBlock() => new(this); + public void Add(int number, int value) => Entries.Add((number, value.ToString(CultureInfo.InvariantCulture))); + public void AddLocalized(int value) => Entries.Add((0, value.ToString(CultureInfo.InvariantCulture))); + public void AddLocalized(int number, int value) => Entries.Add((number, value.ToString(CultureInfo.InvariantCulture))); + public void Add(ref IPropertyList.InterpolatedStringHandler handler) => Entries.Add((0, _interpolated)); + public void Add(int number, ref IPropertyList.InterpolatedStringHandler handler) => Entries.Add((number, _interpolated)); + public void InitializeInterpolation(int literalLength, int formattedCount) => _interpolated = string.Empty; + public void AppendLiteral(string value) => _interpolated += value; + public void AppendFormatted(T value) => _interpolated += value; + public void AppendFormatted(T value, string format) => + _interpolated += value is IFormattable formattable ? formattable.ToString(format, null) : value; + public void AppendFormatted(T value, int alignment) => _interpolated += value; + public void AppendFormatted(T value, int alignment, string format) => + _interpolated += value is IFormattable formattable ? formattable.ToString(format, null) : value; + public void AppendFormatted(ReadOnlySpan value) => _interpolated += value.ToString(); + public void AppendFormatted(ReadOnlySpan value, int alignment, string format = null) => + _interpolated += value.ToString(); + public void AppendFormatted(object value, int alignment = 0, string format = null) => _interpolated += value; + public void AppendFormatted(string value) => _interpolated += value; + public void AppendFormatted(string value, int alignment, string format = null) => _interpolated += value; + } +} diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index 9ab07aa26..56be79748 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -2278,6 +2278,11 @@ public abstract partial class BaseWeapon DoLowerDefense(attacker, defender); } } + + if (a == null && move == null) + { + Sparks.TryProcOnNormalHit(attacker, defender, ExtendedWeaponAttributes.HitSparks); + } } bcAtt?.OnGaveMeleeAttack(defender, damage); diff --git a/Projects/UOContent/Items/Weapons/Sparks.cs b/Projects/UOContent/Items/Weapons/Sparks.cs new file mode 100644 index 000000000..76e993335 --- /dev/null +++ b/Projects/UOContent/Items/Weapons/Sparks.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using ModernUO.CodeGeneratedEvents; +using Server.Collections; +using Server.Engines.BuffIcons; +using Server.Mobiles; + +namespace Server.Items; + +public static class Sparks +{ + internal const int TickCount = 5; + internal const int RawDamageMin = 20; + internal const int RawDamageMax = 40; + + internal static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(1.0); + internal static readonly TimeSpan EffectDuration = TimeSpan.FromSeconds(5.0); + + private static readonly Dictionary<(Mobile Attacker, Mobile Defender), SparksContext> _contexts = []; + + internal static int? RawDamageOverrideForTests { get; set; } + + public static void TryProcOnNormalHit(Mobile attacker, Mobile defender, int hitSparksChance) + { + if (!Core.TOL || hitSparksChance <= 0 || attacker == null || defender == null) + { + return; + } + + if (!IsValidCombatant(attacker) || !IsValidCombatant(defender)) + { + return; + } + + if (hitSparksChance <= Utility.Random(100)) + { + return; + } + + if (_contexts.ContainsKey((attacker, defender))) + { + return; + } + + _contexts[(attacker, defender)] = new SparksContext(attacker, defender); + } + + internal static int ApplySparksTick(Mobile attacker, Mobile defender, int rawDamage) + { + if (!Core.TOL || !IsValidCombatant(attacker) || !IsValidCombatant(defender)) + { + return 0; + } + + var raw = rawDamage; + + if (!defender.Player) + { + raw *= 2; + } + + var defenderHits = defender.Hits; + AOS.Damage(defender, attacker, raw, false, 0, 0, 0, 0, 100); + var damageApplied = Math.Max(0, defenderHits - defender.Hits); + + if (damageApplied > 0 && attacker is { Deleted: false, Alive: true }) + { + attacker.Mana = Math.Min(attacker.ManaMax, attacker.Mana + damageApplied); + } + + return damageApplied; + } + + internal static bool HasActiveContext(Mobile attacker, Mobile defender) => + attacker != null && defender != null && _contexts.ContainsKey((attacker, defender)); + + internal static int ActiveContextCount => _contexts.Count; + + internal static int GetTicksRemainingForTests(Mobile attacker, Mobile defender) => + _contexts.TryGetValue((attacker, defender), out var context) ? context.TicksRemaining : 0; + + internal static bool TickForTests(Mobile attacker, Mobile defender) + { + if (!_contexts.TryGetValue((attacker, defender), out var context)) + { + return false; + } + + context.Tick(); + return true; + } + + internal static void ClearAll() + { + foreach (var context in _contexts.Values) + { + context.Stop(); + } + + _contexts.Clear(); + RawDamageOverrideForTests = null; + } + + [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] + [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] + [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] + [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] + public static void Clear(Mobile mobile) + { + if (mobile == null) + { + return; + } + + using var pairs = PooledRefList<(Mobile Attacker, Mobile Defender)>.Create(); + + foreach (var pair in _contexts.Keys) + { + if (pair.Attacker == mobile || pair.Defender == mobile) + { + pairs.Add(pair); + } + } + + for (var i = 0; i < pairs.Count; i++) + { + var pair = pairs[i]; + StopContext(pair.Attacker, pair.Defender); + } + } + + private static void StopContext(Mobile attacker, Mobile defender) + { + if (_contexts.Remove((attacker, defender), out var context)) + { + context.Stop(); + } + } + + private static bool IsValidCombatant(Mobile mobile) => + mobile is { Deleted: false, Alive: true } && mobile.Map != null && mobile.Map != Map.Internal; + + private static int RollRawDamage() => RawDamageOverrideForTests ?? Utility.RandomMinMax(RawDamageMin, RawDamageMax); + + private sealed class SparksContext + { + private readonly Mobile _attacker; + private readonly Mobile _defender; + private TimerExecutionToken _tickTimerToken; + + public SparksContext(Mobile attacker, Mobile defender) + { + _attacker = attacker; + _defender = defender; + TicksRemaining = TickCount; + + if (_defender is PlayerMobile pm && BuffInfo.Enabled) + { + pm.AddBuff(new BuffInfo(BuffIcon.Sparks, 1157330, 1157361, EffectDuration)); + } + + _attacker.PlaySound(0x20A); + _defender.FixedParticles(0x3818, 1, 11, 0x13A8, 0, 0, EffectLayer.Waist); + + Timer.StartTimer(TickInterval, TickInterval, Tick, out _tickTimerToken); + } + + public int TicksRemaining { get; private set; } + + public void Stop() + { + _tickTimerToken.Cancel(); + + if (_defender is PlayerMobile pm && BuffInfo.Enabled) + { + pm.RemoveBuff(BuffIcon.Sparks); + } + } + + public void Tick() + { + if (!IsValidCombatant(_attacker) || !IsValidCombatant(_defender)) + { + StopContext(_attacker, _defender); + return; + } + + ApplySparksTick(_attacker, _defender, RollRawDamage()); + TicksRemaining--; + + if (TicksRemaining <= 0) + { + StopContext(_attacker, _defender); + } + } + } +} diff --git a/Projects/UOContent/Misc/AOS.cs b/Projects/UOContent/Misc/AOS.cs index 4b969c9d0..aacbad397 100644 --- a/Projects/UOContent/Misc/AOS.cs +++ b/Projects/UOContent/Misc/AOS.cs @@ -1114,7 +1114,8 @@ namespace Server public enum ExtendedWeaponAttribute { Bane = 0x00000001, - BattleLust = 0x00000002 + BattleLust = 0x00000002, + HitSparks = 0x00000004 } public sealed class ExtendedWeaponAttributes : BaseAttributes @@ -1157,6 +1158,13 @@ namespace Server } } + [CommandProperty(AccessLevel.GameMaster)] + public int HitSparks + { + get => this[ExtendedWeaponAttribute.HitSparks]; + set => this[ExtendedWeaponAttribute.HitSparks] = value; + } + public void GetProperties(IPropertyList list) { if (Core.HS && Bane != 0) @@ -1168,6 +1176,11 @@ namespace Server { list.Add(1113710); // Battle Lust } + + if (Core.TOL && HitSparks != 0) + { + list.Add(1157326, HitSparks); // Sparks ~1_val~% + } } public override string ToString() => "...";