feat(items): add Sparks weapon property

This commit is contained in:
Crome696 2026-07-08 23:43:57 +02:00
parent b4e57ee9e5
commit a44cfbd5c9
5 changed files with 721 additions and 1 deletions

View file

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

View file

@ -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<char> argument) => Entries.Add((0, argument.ToString()));
public void Add(int number, ReadOnlySpan<char> argument) => Entries.Add((number, argument.ToString()));
public void AddChunked(ReadOnlySpan<char> 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>(T value) => _interpolated += value;
public void AppendFormatted<T>(T value, string format) =>
_interpolated += value is IFormattable formattable ? formattable.ToString(format, null) : value;
public void AppendFormatted<T>(T value, int alignment) => _interpolated += value;
public void AppendFormatted<T>(T value, int alignment, string format) =>
_interpolated += value is IFormattable formattable ? formattable.ToString(format, null) : value;
public void AppendFormatted(ReadOnlySpan<char> value) => _interpolated += value.ToString();
public void AppendFormatted(ReadOnlySpan<char> 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;
}
}

View file

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

View file

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

View file

@ -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() => "...";