ModernUO/Projects/UOContent.Tests/Tests/Items/SpellFocusingSashTests.cs
Crome696 28513b00dd 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.
2026-07-10 08:03:19 +02:00

280 lines
10 KiB
C#

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