feat: implement Spell Focusing Sash (Closes #22)

Adds the Stygian Abyss-era Spell Focusing Sash with full lifecycle,
equipment stat block, and damage sequence behavior:

- New SpellFocusingSash (BaseMiddleTorso) with Brittle, Mana Increase,
  Defense Chance Increase, +10 Strength Requirement, and 255/255 durability.
- Context-menu enable/disable on the worn sash.
- Sequence damage modifier: -30 -> 0 over six casts, then +2 per cast
  capped at +30 (PvM) or +20 held for five casts (PvP), resetting at the
  peak, on target change, on unequip, on disable, on death, and on logout.
- Centralized eligibility gate (SpellFocusingEligible) and shared
  SpellHelper.Damage hooks, plus a direct hook in PainSpike.
- Brittle is forced on under Core.SA via AOS.IsBrittle.
- Regression tests covering stats/property ordering, enable state
  serialization, context-menu toggle, PvM and PvP sequences, target
  resets, disabled state, and non-eligible spells.
This commit is contained in:
Crome696 2026-07-10 08:03:19 +02:00
parent fa3b9024cb
commit 28513b00dd
18 changed files with 583 additions and 0 deletions

View file

@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Collections;
using Server.ContextMenus;
using Server.Items;
using Server.Spells;
using Server.Tests;
using Server.Text;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class SpellFocusingSashTests
{
private const int SpellFocusingCliloc = 1150058;
private const int BrittleCliloc = 1116209;
private const int ManaIncreaseCliloc = 1060439;
private const int DefendChanceCliloc = 1060408;
private const int StrengthRequirementCliloc = 1061170;
private const int DurabilityCliloc = 1060639;
[Fact]
public void SpellFocusingSash_HasArtifactStatsAndPropertyOrder()
{
var previousExpansion = Core.Expansion;
var sash = new SpellFocusingSash();
try
{
Core.Expansion = Expansion.SA;
var list = new RecordingPropertyList();
sash.GetProperties(list);
Assert.Equal(1.0, sash.DefaultWeight);
Assert.Equal(1, sash.Attributes.BonusMana);
Assert.Equal(5, sash.Attributes.DefendChance);
Assert.Equal(10, sash.StrRequirement);
Assert.Equal(255, sash.HitPoints);
Assert.Equal(255, sash.MaxHitPoints);
var spellFocusingIndex = list.Entries.FindIndex(entry => entry.Number == SpellFocusingCliloc);
Assert.True(spellFocusingIndex > 0);
Assert.Equal(1072788, list.Entries[spellFocusingIndex - 1].Number);
Assert.True(spellFocusingIndex < list.Entries.FindIndex(entry => entry.Number == BrittleCliloc));
Assert.Contains(list.Entries, entry => entry.Number == StrengthRequirementCliloc && entry.Argument == "10");
Assert.Contains(list.Entries, entry => entry.Number == DurabilityCliloc && entry.Argument == "255\t255");
Assert.True(
list.Entries.FindIndex(entry => entry.Number == DefendChanceCliloc) <
list.Entries.FindIndex(entry => entry.Number == ManaIncreaseCliloc)
);
}
finally
{
Core.Expansion = previousExpansion;
sash.Delete();
}
}
[Fact]
public void SpellFocusingSash_EnabledStateSerializesAndContextMenuTogglesIt()
{
var previousExpansion = Core.Expansion;
var caster = CreateMobile(player: true);
var sash = new SpellFocusingSash();
var deserialized = new SpellFocusingSash();
try
{
Core.Expansion = Expansion.SA;
caster.AddItem(sash);
var menu = ContextMenuSystem.CreateContextMenu(caster, sash);
var entry = Assert.Single(menu.Entries, e => e.Number == 3006151);
entry.OnClick(caster, sash);
Assert.False(sash.Enabled);
sash.Enabled = true;
var writer = new BufferWriter(true);
sash.Enabled = false;
sash.Serialize(writer);
var buffer = new byte[writer.Position];
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
deserialized.Deserialize(new BufferReader(buffer));
Assert.False(deserialized.Enabled);
}
finally
{
Core.Expansion = previousExpansion;
sash.Delete();
deserialized.Delete();
caster.Delete();
}
}
[Fact]
public void SpellFocusingSash_AppliesPvMSequenceAndResetsAfterPeak()
{
var previousExpansion = Core.Expansion;
var caster = CreateMobile(player: true);
var target = CreateMobile(player: false);
var sash = new SpellFocusingSash();
var spell = new TestSpell(caster);
try
{
Core.Expansion = Expansion.SA;
caster.AddItem(sash);
var expected = new[] { -30, -24, -18, -12, -6, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30 };
foreach (var expectedOffset in expected)
{
Assert.True(SpellFocusingSash.TryGetDamageOffset(spell, caster, target, out var actual));
Assert.Equal(expectedOffset, actual);
}
Assert.True(SpellFocusingSash.TryGetDamageOffset(spell, caster, target, out var resetOffset));
Assert.Equal(-30, resetOffset);
}
finally
{
Core.Expansion = previousExpansion;
sash.Delete();
target.Delete();
caster.Delete();
}
}
[Fact]
public void SpellFocusingSash_HoldsPvPCapForFiveSpellsAndResetsOnTargetChange()
{
var previousExpansion = Core.Expansion;
var caster = CreateMobile(player: true);
var target = CreateMobile(player: true);
var secondTarget = CreateMobile(player: true);
var sash = new SpellFocusingSash();
var spell = new TestSpell(caster);
try
{
Core.Expansion = Expansion.SA;
caster.AddItem(sash);
for (var i = 0; i < 15; i++)
{
Assert.True(SpellFocusingSash.TryGetDamageOffset(spell, caster, target, out _));
}
for (var i = 0; i < 6; i++)
{
Assert.True(SpellFocusingSash.TryGetDamageOffset(spell, caster, target, out var offset));
Assert.Equal(20, offset);
}
Assert.True(SpellFocusingSash.TryGetDamageOffset(spell, caster, target, out var resetOffset));
Assert.Equal(-30, resetOffset);
Assert.True(SpellFocusingSash.TryGetDamageOffset(spell, caster, secondTarget, out var targetResetOffset));
Assert.Equal(-30, targetResetOffset);
sash.Enabled = false;
Assert.False(SpellFocusingSash.TryGetDamageOffset(spell, caster, target, out _));
}
finally
{
Core.Expansion = previousExpansion;
sash.Delete();
secondTarget.Delete();
target.Delete();
caster.Delete();
}
}
[Fact]
public void SpellFocusingSash_ExcludesNonQualifyingSpells()
{
var previousExpansion = Core.Expansion;
var caster = CreateMobile(player: true);
var target = CreateMobile(player: false);
var sash = new SpellFocusingSash();
try
{
Core.Expansion = Expansion.SA;
caster.AddItem(sash);
Assert.False(SpellFocusingSash.TryGetDamageOffset(new NonQualifyingSpell(caster), caster, target, out _));
}
finally
{
Core.Expansion = previousExpansion;
sash.Delete();
target.Delete();
caster.Delete();
}
}
private static Mobile CreateMobile(bool player)
{
var mobile = new Mobile(World.NewMobile);
mobile.DefaultMobileInit();
mobile.Player = player;
mobile.RawStr = 100;
mobile.Hits = mobile.HitsMax;
return mobile;
}
private sealed class TestSpell : Spell
{
private static readonly SpellInfo TestInfo = new("Test Spell", "test");
public TestSpell(Mobile caster) : base(caster, null, TestInfo)
{
}
public override bool SpellFocusingEligible => true;
public override TimeSpan CastDelayBase => TimeSpan.Zero;
public override void OnCast()
{
}
public override int GetMana() => 0;
}
private sealed class NonQualifyingSpell : Spell
{
private static readonly SpellInfo TestInfo = new("Non-Qualifying Spell", "no");
public NonQualifyingSpell(Mobile caster) : base(caster, null, TestInfo)
{
}
public override TimeSpan CastDelayBase => TimeSpan.Zero;
public override void OnCast()
{
}
public override int GetMana() => 0;
}
private sealed record PropertyEntry(int Number, string Argument);
private sealed class RecordingPropertyList : IPropertyList
{
private string _interpolated = string.Empty;
public List<PropertyEntry> Entries { get; } = [];
public void Reset()
{
}
public void Terminate()
{
}
public void Add(int number) => Entries.Add(new PropertyEntry(number, string.Empty));
public void Add(int number, string argument) => Entries.Add(new PropertyEntry(number, argument));
public void Add(ReadOnlySpan<char> argument) => Entries.Add(new PropertyEntry(0, argument.ToString()));
public void Add(int number, ReadOnlySpan<char> argument) => Entries.Add(new PropertyEntry(number, argument.ToString()));
public void AddChunked(ReadOnlySpan<char> text) => Entries.Add(new PropertyEntry(0, text.ToString()));
public OplTextBlock TextBlock() => new(this);
public void Add(int number, int value) => Entries.Add(new PropertyEntry(number, value.ToString()));
public void AddLocalized(int value) => Entries.Add(new PropertyEntry(0, value.ToString()));
public void AddLocalized(int number, int value) => Entries.Add(new PropertyEntry(number, value.ToString()));
public void Add(ref IPropertyList.InterpolatedStringHandler handler) => Entries.Add(new PropertyEntry(0, _interpolated));
public void Add(int number, ref IPropertyList.InterpolatedStringHandler handler) => Entries.Add(new PropertyEntry(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

@ -0,0 +1,248 @@
using ModernUO.CodeGeneratedEvents;
using ModernUO.Serialization;
using Server.Collections;
using Server.ContextMenus;
using Server.Engines.BuffIcons;
using Server.Mobiles;
using Server.Spells;
namespace Server.Items;
[Flippable(0x1541, 0x1542)]
[SerializationGenerator(0, false)]
public partial class SpellFocusingSash : BaseMiddleTorso
{
private const int SpellFocusingCliloc = 1150058;
private const int BrittleCliloc = 1116209;
private const int ResetMessage = 1150117;
private const int TunedMessage = 1150118;
private const int PeakMessage = 1150116;
private const int BuffTitleCliloc = 1151391;
private const int BuffSecondaryCliloc = 1151392;
private const int SequenceLength = 21;
private Mobile _spellCastTarget;
private int _spellCastCount;
private bool _enabled = true;
[Constructible]
public SpellFocusingSash() : base(0x1541)
{
Attributes.BonusMana = 1;
Attributes.DefendChance = 5;
HitPoints = MaxHitPoints = 255;
}
public override int LabelNumber => 1150059;
public override double DefaultWeight => 1.0;
public override int InitMinHits => 255;
public override int InitMaxHits => 255;
public override int AosStrReq => 10;
[SerializableProperty(0, useField: nameof(_enabled))]
[CommandProperty(AccessLevel.GameMaster)]
public bool Enabled
{
get => _enabled;
set
{
if (_enabled == value)
{
return;
}
_enabled = value;
ResetSequence(Parent as Mobile);
InvalidateProperties();
this.MarkDirty();
}
}
public static void Configure()
{
EventSink.Logout += Clear;
}
public override void AddNameProperties(IPropertyList list)
{
base.AddNameProperties(list);
if (Core.SA)
{
list.Add(SpellFocusingCliloc);
list.Add(BrittleCliloc);
}
}
public override void GetContextMenuEntries(Mobile from, ref PooledRefList<ContextMenuEntry> list)
{
base.GetContextMenuEntries(from, ref list);
if (Core.SA && from == Parent && from.Alive)
{
list.Add(new ToggleSpellFocusingEntry(this));
}
}
public override void OnAdded(IEntity parent)
{
base.OnAdded(parent);
if (parent is Mobile mobile)
{
ResetSequence(mobile);
}
}
public override void OnRemoved(IEntity parent)
{
if (parent is Mobile mobile)
{
ResetSequence(mobile);
}
base.OnRemoved(parent);
}
public override void OnDelete()
{
ResetSequence(Parent as Mobile);
base.OnDelete();
}
public static bool TryGetDamageOffset(Spell spell, Mobile caster, Mobile target, out int offset)
{
offset = 0;
if (!Core.SA || spell?.SpellFocusingEligible != true || caster?.Deleted != false)
{
return false;
}
if (caster.FindItemOnLayer(Layer.MiddleTorso) is not SpellFocusingSash sash || !sash.Enabled)
{
return false;
}
if (target?.Deleted != false || !target.Alive || !caster.Alive)
{
sash.ResetSequence(caster);
return false;
}
return sash.TryGetDamageOffset(caster, target, out offset);
}
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
[OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))]
[OnEvent(nameof(BaseCreature.CreatureDeathEvent))]
[OnEvent(nameof(BaseCreature.CreatureDeletedEvent))]
public static void Clear(Mobile mobile)
{
if (mobile?.FindItemOnLayer(Layer.MiddleTorso) is SpellFocusingSash sash)
{
sash.ResetSequence(mobile);
}
}
private bool TryGetDamageOffset(Mobile caster, Mobile target, out int offset)
{
offset = 0;
if (_spellCastTarget?.Deleted != false || !_spellCastTarget.Alive)
{
ResetSequence(caster);
}
if (_spellCastTarget != target)
{
if (_spellCastTarget != null)
{
caster.SendLocalizedMessage(ResetMessage);
}
ResetSequence(caster);
_spellCastTarget = target;
}
offset = GetDamageOffset(_spellCastCount, target.Player);
_spellCastCount++;
if (offset == 0)
{
caster.SendLocalizedMessage(TunedMessage);
}
if (_spellCastCount >= SequenceLength)
{
caster.SendLocalizedMessage(PeakMessage);
ResetSequence(caster);
}
else
{
RefreshBuffInfo(caster, offset);
}
return true;
}
private static int GetDamageOffset(int castCount, bool playerTarget)
{
if (castCount < 6)
{
return -30 + castCount * 6;
}
var offset = (castCount - 5) * 2;
return playerTarget ? System.Math.Min(offset, 20) : System.Math.Min(offset, 30);
}
private void ResetSequence(Mobile caster)
{
_spellCastTarget = null;
_spellCastCount = 0;
if (caster is PlayerMobile player)
{
player.RemoveBuff(BuffIcon.SpellFocusingBuff);
player.RemoveBuff(BuffIcon.SpellFocusingDebuff);
}
}
private void RefreshBuffInfo(Mobile caster, int offset)
{
if (caster is not PlayerMobile player || caster != Parent || !Enabled)
{
return;
}
player.RemoveBuff(offset < 0 ? BuffIcon.SpellFocusingBuff : BuffIcon.SpellFocusingDebuff);
player.AddBuff(
new BuffInfo(
offset < 0 ? BuffIcon.SpellFocusingDebuff : BuffIcon.SpellFocusingBuff,
BuffTitleCliloc,
BuffSecondaryCliloc,
args: $"{_spellCastTarget?.Name ?? "None"}\t{offset}"
)
);
}
private sealed class ToggleSpellFocusingEntry : ContextMenuEntry
{
private readonly SpellFocusingSash _sash;
public ToggleSpellFocusingEntry(SpellFocusingSash sash) : base(sash.Enabled ? 3006151 : 3006150, 2)
{
_sash = sash;
}
public override void OnClick(Mobile from, IEntity target)
{
if (target is SpellFocusingSash sash && sash == _sash && from == sash.Parent && from.Alive)
{
sash.Enabled = !sash.Enabled;
from.SendMessage(sash.Enabled ? "Spell Focusing enabled." : "Spell Focusing disabled.");
}
}
}
}

View file

@ -1359,6 +1359,11 @@ namespace Server
public static bool IsBrittle(Item item)
{
if (item is SpellFocusingSash)
{
return Core.SA;
}
if (!Core.HS)
{
return false;

View file

@ -57,6 +57,8 @@ namespace Server.Spells
public virtual bool DelayedDamage => false;
public virtual bool SpellFocusingEligible => false;
public static readonly Type[] AOSNoDelayedDamageStackingSelf = Core.AOS ? Array.Empty<Type>() : null;
// Null means stacking is allowed while empty indicates no stacking with self

View file

@ -958,6 +958,11 @@ namespace Server.Spells
bcFrom?.AlterSpellDamageTo(target, ref damageGiven);
bcTarget?.AlterSpellDamageFrom(from, ref damageGiven);
if (SpellFocusingSash.TryGetDamageOffset(spell, from, target, out var spellFocusingOffset))
{
damageGiven = AOS.Scale(damageGiven, 100 + spellFocusingOffset);
}
target.Damage(damageGiven, from);
bcFrom?.OnDamageSpell(target, damageGiven);
@ -1026,6 +1031,11 @@ namespace Server.Spells
bcTarget?.AlterSpellDamageFrom(from, ref dmg);
if (SpellFocusingSash.TryGetDamageOffset(spell, from, target, out var spellFocusingOffset))
{
dmg = AOS.Scale(dmg, 100 + spellFocusingOffset);
}
if (Feint.GetDamageReduction(from, target, out var feintReduction))
{
// example: 35 damage * 50 / 100 = 17 damage
@ -1110,6 +1120,11 @@ namespace Server.Spells
(m_From as BaseCreature)?.AlterSpellDamageTo(m_Target, ref m_Damage);
(m_Target as BaseCreature)?.AlterSpellDamageFrom(m_From, ref m_Damage);
if (SpellFocusingSash.TryGetDamageOffset(m_Spell, m_From, m_Target, out var spellFocusingOffset))
{
m_Damage = AOS.Scale(m_Damage, 100 + spellFocusingOffset);
}
m_Target.Damage(m_Damage);
m_Spell?.RemoveDelayedDamageContext(m_Target);
}

View file

@ -26,6 +26,8 @@ namespace Server.Spells.Fifth
public override SpellCircle Circle => SpellCircle.Fifth;
public override bool SpellFocusingEligible => true;
public override bool DelayedDamage => !Core.AOS;
public void Target(Mobile m)

View file

@ -20,6 +20,8 @@ namespace Server.Spells.First
public override SpellCircle Circle => SpellCircle.First;
public override bool SpellFocusingEligible => true;
private static readonly Type[] _delayedDamageSpellFamilyStacking = Core.AOS ? [typeof(NetherBoltSpell)] : null;
public override Type[] DelayedDamageSpellFamilyStacking => _delayedDamageSpellFamilyStacking;

View file

@ -19,6 +19,8 @@ namespace Server.Spells.Fourth
public override SpellCircle Circle => SpellCircle.Fourth;
public override bool SpellFocusingEligible => true;
public override bool DelayedDamage => false;
public void Target(Mobile m)

View file

@ -20,6 +20,8 @@ public class BombardSpell : MysticSpell, ITargetingSpell<Mobile>
}
public override SpellCircle Circle => SpellCircle.Sixth;
public override bool SpellFocusingEligible => true;
public override bool DelayedDamage => true;
public override Type[] DelayedDamageSpellFamilyStacking => AOSNoDelayedDamageStackingSelf;

View file

@ -22,6 +22,8 @@ public class EagleStrikeSpell : MysticSpell, ITargetingSpell<Mobile>
public override SpellCircle Circle => SpellCircle.Third;
public override bool SpellFocusingEligible => true;
public void Target(Mobile m)
{
if (CheckHSequence(m))

View file

@ -23,6 +23,8 @@ public class NetherBoltSpell : MysticSpell, ITargetingSpell<Mobile>
public override SpellCircle Circle => SpellCircle.First;
public override bool SpellFocusingEligible => true;
public override bool DelayedDamage => true;
public override Type[] DelayedDamageSpellFamilyStacking => _delayedDamageSpellFamilyStacking;

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using Server.Engines.BuffIcons;
using Server.Items;
using Server.Misc;
using Server.Mobiles;
using Server.Targeting;
@ -31,6 +32,8 @@ public class PainSpikeSpell : NecromancerSpell, ITargetingSpell<Mobile>
public override bool DelayedDamage => false;
public override bool SpellFocusingEligible => true;
public static bool UnderEffect(Mobile m) => _table.ContainsKey(m);
public void Target(Mobile m)
@ -76,6 +79,12 @@ public class PainSpikeSpell : NecromancerSpell, ITargetingSpell<Mobile>
// TODO: Find a better way to do this
StaminaSystem.DFA = DFAlgorithm.PainSpike;
if (SpellFocusingSash.TryGetDamageOffset(this, Caster, m, out var spellFocusingOffset))
{
damage = AOS.Scale((int)damage, 100 + spellFocusingOffset);
}
m.Damage((int)damage, Caster, ignoreEvilOmen: true);
SpellHelper.DoLeech((int)damage, Caster, m);
StaminaSystem.DFA = DFAlgorithm.Standard;

View file

@ -19,6 +19,8 @@ namespace Server.Spells.Second
public override SpellCircle Circle => SpellCircle.Second;
public override bool SpellFocusingEligible => true;
public override bool DelayedDamage => false;
public void Target(Mobile m)

View file

@ -19,6 +19,8 @@ namespace Server.Spells.Seventh
public override SpellCircle Circle => SpellCircle.Seventh;
public override bool SpellFocusingEligible => true;
public override bool DelayedDamage => true;
public void Target(Mobile m)

View file

@ -19,6 +19,8 @@ namespace Server.Spells.Sixth
public override SpellCircle Circle => SpellCircle.Sixth;
public override bool SpellFocusingEligible => true;
public override bool DelayedDamage => true;
public void Target(Mobile m)

View file

@ -20,6 +20,8 @@ namespace Server.Spells.Sixth
public override SpellCircle Circle => SpellCircle.Sixth;
public override bool SpellFocusingEligible => true;
public override Type[] DelayedDamageSpellFamilyStacking => AOSNoDelayedDamageStackingSelf;
public override bool DelayedDamage => false;

View file

@ -11,6 +11,8 @@ namespace Server.Spells.Spellweaving
{
}
public override bool SpellFocusingEligible => true;
public override TimeSpan CastDelayBase => TimeSpan.FromSeconds(3.5);
public override double RequiredSkill => 80.0;

View file

@ -18,6 +18,8 @@ namespace Server.Spells.Third
public override SpellCircle Circle => SpellCircle.Third;
public override bool SpellFocusingEligible => true;
public override bool DelayedDamage => true;
public void Target(Mobile m)