feat: implement mysticism enchant spell

This commit is contained in:
Crome696 2026-07-10 08:50:42 +02:00
parent 1ad7593ddd
commit cd526022fa
6 changed files with 1047 additions and 7 deletions

View file

@ -0,0 +1,548 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Reflection;
using Server.Gumps;
using Server.Items;
using Server.Mobiles;
using Server.Network;
using Server.Spells;
using Server.Spells.Mysticism;
using Server.Spells.Ninjitsu;
using Server.Tests;
using Xunit;
namespace Server.Tests.Spells.Mysticism;
[Collection("Sequential UOContent Tests")]
public class EnchantSpellTests
{
[Fact]
public void SpellMetadata_MatchesEnchantSources()
{
var caster = NewCaster();
var spell = new EnchantSpell(caster);
Assert.Equal("Enchant", spell.Name);
Assert.Equal("In Ort Ylem", spell.Mantra);
Assert.Equal(SpellCircle.Second, spell.Circle);
Assert.Equal(TimeSpan.FromSeconds(0.75), spell.CastDelayBase);
Assert.Equal(6, spell.GetMana());
Assert.Equal(8.0, spell.RequiredSkill);
Assert.Equal(SkillName.Mysticism, spell.CastSkill);
Assert.Equal([Reagent.SpidersSilk, Reagent.MandrakeRoot, Reagent.SulfurousAsh], spell.Reagents);
Assert.False(spell.ClearHandsOnCast);
caster.Delete();
}
[Fact]
public void MysticSpellbookAndScroll_UseEnchantSlot()
{
var book = new MysticSpellbook(1UL << (680 - 677));
var scroll = new EnchantScroll();
Assert.Equal(677, book.BookOffset);
Assert.Equal(16, book.BookCount);
Assert.True(book.HasSpell(680));
Assert.Equal(680, GetSpellScrollId(scroll));
book.Delete();
scroll.Delete();
}
[Fact]
public void RegisterMysticism_PreSaDoesNotExposeEnchant_AndSaUsesSpellId680()
{
var previousExpansion = Core.Expansion;
var caster = NewCaster();
try
{
ResetSpellRegistry();
Core.Expansion = Expansion.ML;
Initializer.Configure();
Assert.Null(SpellRegistry.NewSpell(680, caster, null));
Assert.Equal(-1, SpellRegistry.GetRegistryNumber(typeof(EnchantSpell)));
ResetSpellRegistry();
Core.Expansion = Expansion.SA;
Initializer.Configure();
Assert.Same(typeof(EnchantSpell), SpellRegistry.Types[680]);
Assert.Equal(680, SpellRegistry.GetRegistryNumber(typeof(EnchantSpell)));
Assert.IsType<EnchantSpell>(SpellRegistry.NewSpell(680, caster, null));
}
finally
{
Core.Expansion = previousExpansion;
ResetSpellRegistry();
Initializer.Configure();
caster.Delete();
}
}
[Fact]
public void Duration_ScalesWithSelectedHitSpellLevelAndCapsAt150Seconds()
{
Assert.Equal(TimeSpan.FromSeconds(30), EnchantSpell.GetDuration(AosWeaponAttribute.HitMagicArrow));
Assert.Equal(TimeSpan.FromSeconds(60), EnchantSpell.GetDuration(AosWeaponAttribute.HitHarm));
Assert.Equal(TimeSpan.FromSeconds(90), EnchantSpell.GetDuration(AosWeaponAttribute.HitFireball));
Assert.Equal(TimeSpan.FromSeconds(120), EnchantSpell.GetDuration(AosWeaponAttribute.HitLightning));
Assert.Equal(TimeSpan.FromSeconds(150), EnchantSpell.GetDuration(AosWeaponAttribute.HitDispel));
}
[Fact]
public void HitSpellChance_UsesHigherFocusOrImbuingAndCapsAt60()
{
var focusCaster = NewCaster(120, 120, 0);
var imbuingCaster = NewCaster(120, 0, 120);
var lowCaster = NewCaster(80, 0, 0);
Assert.Equal(60, EnchantSpell.GetHitSpellChance(focusCaster));
Assert.Equal(60, EnchantSpell.GetHitSpellChance(imbuingCaster));
var lowExpected = (int)(60 * (lowCaster.Skills.Mysticism.Value +
Math.Max(lowCaster.Skills.Focus.Value, lowCaster.Skills.Imbuing.Value)) / 240.0);
Assert.Equal(lowExpected, EnchantSpell.GetHitSpellChance(lowCaster));
focusCaster.Delete();
imbuingCaster.Delete();
lowCaster.Delete();
}
[Fact]
public void Selection_AppliesRuntimeOnlyHitSpellAndChannelingState()
{
var previousExpansion = Core.Expansion;
Core.Expansion = Expansion.SA;
var caster = NewCaster();
var weapon = new Katana();
AddReagents(caster);
Assert.True(caster.EquipItem(weapon));
try
{
var spell = BeginSelection(caster);
var baselineCastDelay = new NetherBoltSpell(caster).GetCastDelay();
spell.FinishSelection(weapon, AosWeaponAttribute.HitLightning);
Assert.Null(caster.Spell);
Assert.Equal(SpellState.None, spell.State);
Assert.True(EnchantSpell.IsEnchanted(weapon));
Assert.Equal(60, EnchantSpell.GetHitSpellBonus(weapon, AosWeaponAttribute.HitLightning));
Assert.Equal(0, weapon.WeaponAttributes.HitLightning);
Assert.Equal(0, weapon.Attributes.SpellChanneling);
Assert.True(EnchantSpell.ProvidesSpellChanneling(weapon, caster));
Assert.Equal(-1, EnchantSpell.GetFasterCasting(caster));
Assert.Equal(TimeSpan.FromSeconds(0.25), new NetherBoltSpell(caster).GetCastDelay() - baselineCastDelay);
Assert.True(weapon.AllowEquippedCast(caster));
Assert.Equal(94, caster.Mana);
Assert.Equal(9, caster.Backpack.FindItemByType<SpidersSilk>().Amount);
Assert.Equal(9, caster.Backpack.FindItemByType<MandrakeRoot>().Amount);
Assert.Equal(9, caster.Backpack.FindItemByType<SulfurousAsh>().Amount);
caster.RemoveItem(weapon);
Assert.False(EnchantSpell.IsEnchanted(weapon));
Assert.Equal(0, EnchantSpell.GetHitSpellBonus(weapon, AosWeaponAttribute.HitLightning));
Assert.Equal(0, EnchantSpell.GetFasterCasting(caster));
weapon.Attributes.SpellChanneling = 1;
AddReagents(caster);
Assert.True(caster.EquipItem(weapon));
var permanentChannelingSpell = BeginSelection(caster);
permanentChannelingSpell.FinishSelection(weapon, AosWeaponAttribute.HitHarm);
Assert.False(EnchantSpell.ProvidesSpellChanneling(weapon, caster));
Assert.Equal(-1, EnchantSpell.GetFasterCasting(caster));
}
finally
{
EnchantSpell.StopEffect(weapon);
weapon.Delete();
caster.Delete();
Core.Expansion = previousExpansion;
}
}
[Fact]
public void Selection_BelowThresholdDoesNotGrantTemporaryChannelingOrFasterCasting()
{
var previousExpansion = Core.Expansion;
Core.Expansion = Expansion.SA;
var caster = NewCaster(79, 120, 0);
var weapon = new Katana();
AddReagents(caster);
Assert.True(caster.EquipItem(weapon));
try
{
var spell = BeginSelection(caster);
spell.FinishSelection(weapon, AosWeaponAttribute.HitMagicArrow);
Assert.True(EnchantSpell.IsEnchanted(weapon));
Assert.False(EnchantSpell.ProvidesSpellChanneling(weapon, caster));
Assert.Equal(0, EnchantSpell.GetFasterCasting(caster));
var expected = (int)(60 * (caster.Skills.Mysticism.Value +
Math.Max(caster.Skills.Focus.Value, caster.Skills.Imbuing.Value)) / 240.0);
Assert.Equal(expected, EnchantSpell.GetHitSpellBonus(weapon, AosWeaponAttribute.HitMagicArrow));
}
finally
{
EnchantSpell.StopEffect(weapon);
weapon.Delete();
caster.Delete();
Core.Expansion = previousExpansion;
}
}
[Fact]
public void Threshold_At80MysticismAndSupportSkillGrantsAdvancedEffects()
{
var previousExpansion = Core.Expansion;
Core.Expansion = Expansion.SA;
var caster = NewCaster(80, 80, 0);
var weapon = new Katana();
AddReagents(caster);
try
{
Assert.True(caster.EquipItem(weapon));
var spell = BeginSelection(caster);
spell.FinishSelection(weapon, AosWeaponAttribute.HitMagicArrow);
Assert.True(EnchantSpell.ProvidesSpellChanneling(weapon, caster));
Assert.Equal(-1, EnchantSpell.GetFasterCasting(caster));
}
finally
{
EnchantSpell.StopEffect(weapon);
weapon.Delete();
caster.Delete();
Core.Expansion = previousExpansion;
}
}
[Fact]
public void CheckCast_RejectsExistingHitSpellAndIncompatibleEnchantments()
{
var previousExpansion = Core.Expansion;
Core.Expansion = Expansion.SA;
var caster = NewCaster();
var weapon = new Katana();
Assert.True(caster.EquipItem(weapon));
try
{
var spell = new EnchantSpell(caster);
weapon.WeaponAttributes.HitFireball = 1;
Assert.False(spell.CheckCast());
weapon.WeaponAttributes.HitFireball = 0;
weapon.Consecrated = true;
Assert.False(spell.CheckCast());
weapon.Consecrated = false;
caster.Skills.Ninjitsu.Base = 120.0;
SpecialMove.Table[caster] = new FocusAttack();
Assert.False(spell.CheckCast());
SpecialMove.Table.Remove(caster);
}
finally
{
SpecialMove.Table.Remove(caster);
weapon.Delete();
caster.Delete();
Core.Expansion = previousExpansion;
}
}
[Fact]
public void Expiry_RemovesTemporaryStateAndCasterLifecycleBreaksEffect()
{
var previousExpansion = Core.Expansion;
Core.Expansion = Expansion.SA;
var caster = NewCaster();
var weapon = new Katana();
AddReagents(caster);
Assert.True(caster.EquipItem(weapon));
try
{
var spell = BeginSelection(caster);
spell.FinishSelection(weapon, AosWeaponAttribute.HitDispel);
Assert.True(EnchantSpell.IsEnchanted(weapon));
EnchantSpell.ExpireForTests(weapon);
Assert.False(EnchantSpell.IsEnchanted(weapon));
Assert.Equal(0, EnchantSpell.GetHitSpellBonus(weapon, AosWeaponAttribute.HitDispel));
Assert.Equal(0, EnchantSpell.GetFasterCasting(caster));
spell = BeginSelection(caster);
spell.FinishSelection(weapon, AosWeaponAttribute.HitHarm);
Assert.True(EnchantSpell.IsEnchanted(weapon));
caster.RemoveItem(weapon);
Assert.False(EnchantSpell.IsEnchanted(weapon));
Assert.True(caster.EquipItem(weapon));
spell = BeginSelection(caster);
spell.FinishSelection(weapon, AosWeaponAttribute.HitHarm);
Assert.True(EnchantSpell.IsEnchanted(weapon));
EnchantSpell.Configure();
EventSink.InvokeLogout(caster);
Assert.False(EnchantSpell.IsEnchanted(weapon));
spell = BeginSelection(caster);
spell.FinishSelection(weapon, AosWeaponAttribute.HitHarm);
Assert.True(EnchantSpell.IsEnchanted(weapon));
PlayerMobile.PlayerDeletedEvent(caster);
Assert.False(EnchantSpell.IsEnchanted(weapon));
Assert.Equal(0, EnchantSpell.GetHitSpellBonus(weapon, AosWeaponAttribute.HitHarm));
}
finally
{
EnchantSpell.StopEffect(weapon);
weapon.Delete();
caster.Delete();
Core.Expansion = previousExpansion;
}
}
[Fact]
public void OnHit_UsesTemporaryHitSpellBonus()
{
var previousExpansion = Core.Expansion;
using var random = new PredictableRandom(0);
Core.Expansion = Expansion.SA;
var caster = NewCaster();
caster.MoveToWorld(new Point3D(6200, 520, 0), Map.Felucca);
var weapon = new TestEnchantKatana();
var baselineDefender = NewCombatMobile(new Point3D(6201, 520, 0));
var enchantedDefender = NewCombatMobile(new Point3D(6201, 520, 0));
AddReagents(caster);
try
{
Assert.True(caster.EquipItem(weapon));
var baselineHits = baselineDefender.Hits;
weapon.OnHit(caster, baselineDefender);
var baselineDamage = baselineHits - baselineDefender.Hits;
var spell = BeginSelection(caster);
spell.FinishSelection(weapon, AosWeaponAttribute.HitMagicArrow);
weapon.OnHit(caster, enchantedDefender);
Assert.Equal(1, baselineDamage);
Assert.Equal(1, weapon.MagicArrowCount);
}
finally
{
EnchantSpell.StopEffect(weapon);
weapon.Delete();
baselineDefender.Delete();
enchantedDefender.Delete();
caster.Delete();
Core.Expansion = previousExpansion;
}
}
[Fact]
public void SelectionGumpResponse_AppliesSelectedEffect()
{
var previousExpansion = Core.Expansion;
Core.Expansion = Expansion.SA;
var caster = NewCaster();
var weapon = new Katana();
AddReagents(caster);
try
{
Assert.True(caster.EquipItem(weapon));
var spell = BeginSelection(caster);
var gump = new EnchantGump(spell, weapon);
var relay = new RelayInfo(
1,
ReadOnlySpan<int>.Empty,
ReadOnlySpan<ushort>.Empty,
ReadOnlySpan<Range>.Empty,
ReadOnlySpan<byte>.Empty
);
gump.OnResponse(null, in relay);
Assert.True(EnchantSpell.IsEnchanted(weapon));
Assert.Equal(60, EnchantSpell.GetHitSpellBonus(weapon, AosWeaponAttribute.HitLightning));
}
finally
{
EnchantSpell.StopEffect(weapon);
weapon.Delete();
caster.Delete();
Core.Expansion = previousExpansion;
}
}
[Fact]
public void SelectionCancel_DoesNotConsumeManaOrReagents()
{
var previousExpansion = Core.Expansion;
Core.Expansion = Expansion.SA;
var caster = NewCaster();
var weapon = new Katana();
AddReagents(caster);
try
{
Assert.True(caster.EquipItem(weapon));
var spell = BeginSelection(caster);
var gump = new EnchantGump(spell, weapon);
var relay = new RelayInfo(
0,
ReadOnlySpan<int>.Empty,
ReadOnlySpan<ushort>.Empty,
ReadOnlySpan<Range>.Empty,
ReadOnlySpan<byte>.Empty
);
gump.OnResponse(null, in relay);
Assert.Null(caster.Spell);
Assert.False(EnchantSpell.IsEnchanted(weapon));
Assert.Equal(100, caster.Mana);
Assert.Equal(10, caster.Backpack.FindItemByType<SpidersSilk>().Amount);
Assert.Equal(10, caster.Backpack.FindItemByType<MandrakeRoot>().Amount);
Assert.Equal(10, caster.Backpack.FindItemByType<SulfurousAsh>().Amount);
}
finally
{
EnchantSpell.StopEffect(weapon);
weapon.Delete();
caster.Delete();
Core.Expansion = previousExpansion;
}
}
[Fact]
public void SelectionGump_CompilesWithVisibleLayout()
{
var caster = NewCaster();
var weapon = new Katana();
var spell = new EnchantSpell(caster);
var gump = new EnchantGump(spell, weapon);
var buffer = GC.AllocateUninitializedArray<byte>(2048);
var writer = new SpanWriter(buffer);
gump.Compile(ref writer);
Assert.True(writer.BytesWritten > 0);
weapon.Delete();
caster.Delete();
}
private static PlayerMobile NewCaster(double mysticism = 120.0, double focus = 120.0, double imbuing = 0.0)
{
var caster = new PlayerMobile(World.NewMobile);
caster.DefaultMobileInit();
caster.Player = true;
caster.InitStats(100, 100, 100);
caster.Mana = caster.ManaMax;
caster.AddItem(new Backpack());
caster.Skills.Mysticism.Base = mysticism;
caster.Skills.Focus.Base = focus;
caster.Skills.Imbuing.Base = imbuing;
return caster;
}
private static Mobile NewCombatMobile(Point3D location)
{
var mobile = new Mobile(World.NewMobile);
mobile.DefaultMobileInit();
mobile.InitStats(100, 100, 100);
mobile.Hits = mobile.HitsMax;
mobile.MoveToWorld(location, Map.Felucca);
return mobile;
}
private static TestEnchantSpell BeginSelection(PlayerMobile caster)
{
var spell = new TestEnchantSpell(caster);
caster.Spell = spell;
spell.State = SpellState.Sequencing;
return spell;
}
private static void AddReagents(Mobile caster)
{
caster.AddToBackpack(new SpidersSilk(10));
caster.AddToBackpack(new MandrakeRoot(10));
caster.AddToBackpack(new SulfurousAsh(10));
}
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 TestEnchantKatana : Katana
{
public int MagicArrowCount { get; private set; }
public override bool CheckHit(Mobile attacker, Mobile defender) => true;
public override int ComputeDamage(Mobile attacker, Mobile defender) => 1;
public override void DoMagicArrow(Mobile attacker, Mobile defender)
{
MagicArrowCount++;
base.DoMagicArrow(attacker, defender);
}
public override int AbsorbDamage(Mobile attacker, Mobile defender, int damage) => damage;
public override void AddBlood(Mobile attacker, Mobile defender, int damage)
{
}
}
private sealed class TestEnchantSpell : EnchantSpell
{
public TestEnchantSpell(Mobile caster) : base(caster)
{
}
public override bool CheckFizzle() => true;
}
}

View file

@ -0,0 +1,90 @@
using Server.Network;
using Server.Spells;
using Server.Spells.Mysticism;
using Server.Items;
namespace Server.Gumps;
public sealed class EnchantGump : StaticGump<EnchantGump>
{
private readonly EnchantSpell _spell;
private readonly BaseWeapon _weapon;
public EnchantGump(EnchantSpell spell, BaseWeapon weapon) : base(20, 20)
{
_spell = spell;
_weapon = weapon;
}
public override bool Singleton => true;
public static void DisplayTo(Mobile from, EnchantSpell spell, BaseWeapon weapon)
{
if (from == null || from.Deleted || from.NetState == null || spell == null || spell.Caster != from ||
from.Spell != spell || spell.State != SpellState.Sequencing || weapon == null || weapon.Deleted ||
weapon.Parent != from)
{
return;
}
from.CloseGump<EnchantGump>();
from.SendGump(new EnchantGump(spell, weapon));
}
protected override void BuildLayout(ref StaticGumpBuilder builder)
{
const int font = 0x07FF;
builder.AddPage();
builder.AddBackground(0, 0, 260, 187, 3600);
builder.AddAlphaRegion(5, 15, 242, 170);
builder.AddImageTiled(220, 15, 30, 162, 10464);
builder.AddItem(0, 3, 6882);
builder.AddItem(-8, 170, 6880);
builder.AddItem(185, 3, 6883);
builder.AddItem(192, 170, 6881);
builder.AddHtmlLocalized(20, 22, 150, 16, 1080133, font, false, false); // Select Enchant
AddOption(ref builder, 20, 50, 1, 1079705); // Hit Lightning
AddOption(ref builder, 20, 75, 2, 1079703); // Hit Fireball
AddOption(ref builder, 20, 100, 3, 1079704); // Hit Harm
AddOption(ref builder, 20, 125, 4, 1079706); // Hit Magic Arrow
AddOption(ref builder, 20, 150, 5, 1079702); // Hit Dispel
}
public override void OnResponse(NetState sender, in RelayInfo info)
{
var attribute = info.ButtonID switch
{
1 => AosWeaponAttribute.HitLightning,
2 => AosWeaponAttribute.HitFireball,
3 => AosWeaponAttribute.HitHarm,
4 => AosWeaponAttribute.HitMagicArrow,
5 => AosWeaponAttribute.HitDispel,
_ => (AosWeaponAttribute?)null
};
if (attribute.HasValue)
{
_spell.FinishSelection(_weapon, attribute.Value);
}
else
{
_spell.CancelSelection();
}
}
private static void AddOption(
ref StaticGumpBuilder builder,
int x,
int y,
int buttonId,
int cliloc
)
{
builder.AddButton(x, y, 9702, 9703, buttonId);
builder.AddHtmlLocalized(x + 25, y, 200, 16, cliloc, 0x07FF, false, false);
}
}

View file

@ -15,6 +15,7 @@ using Server.Spells.Bushido;
using Server.Spells.Chivalry;
using Server.Spells.Necromancy;
using Server.Spells.Ninjitsu;
using Server.Spells.Mysticism;
using Server.Spells.Sixth;
using Server.Spells.Spellweaving;
using Server.Text;
@ -1187,6 +1188,7 @@ public abstract partial class BaseWeapon
public override void OnRemoved(IEntity parent)
{
ClearLastParryChance();
EnchantSpell.StopEffect(this);
if (parent is not Mobile m)
{
@ -1234,9 +1236,16 @@ public abstract partial class BaseWeapon
m.Delta(MobileDelta.WeaponDamage);
}
public override void OnAfterDelete()
{
EnchantSpell.StopEffect(this);
base.OnAfterDelete();
}
public override void OnMapChange()
{
base.OnMapChange();
EnchantSpell.StopEffect(this);
if ((Map == null || Map == Map.Internal) && Parent is Mobile m && ExtendedWeaponAttributes.BattleLust != 0)
{
@ -2291,15 +2300,20 @@ public abstract partial class BaseWeapon
}
var maChance =
(int)(AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitMagicArrow) * propertyBonus);
(int)((AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitMagicArrow) +
EnchantSpell.GetHitSpellBonus(this, AosWeaponAttribute.HitMagicArrow)) * propertyBonus);
var harmChance =
(int)(AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitHarm) * propertyBonus);
(int)((AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitHarm) +
EnchantSpell.GetHitSpellBonus(this, AosWeaponAttribute.HitHarm)) * propertyBonus);
var fireballChance =
(int)(AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitFireball) * propertyBonus);
(int)((AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitFireball) +
EnchantSpell.GetHitSpellBonus(this, AosWeaponAttribute.HitFireball)) * propertyBonus);
var lightningChance =
(int)(AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitLightning) * propertyBonus);
(int)((AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitLightning) +
EnchantSpell.GetHitSpellBonus(this, AosWeaponAttribute.HitLightning)) * propertyBonus);
var dispelChance =
(int)(AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitDispel) * propertyBonus);
(int)((AosWeaponAttributes.GetValue(attacker, AosWeaponAttribute.HitDispel) +
EnchantSpell.GetHitSpellBonus(this, AosWeaponAttribute.HitDispel)) * propertyBonus);
if (maChance != 0 && maChance > Utility.Random(100))
{
@ -3063,7 +3077,7 @@ public abstract partial class BaseWeapon
}
public override bool AllowEquippedCast(Mobile from) =>
base.AllowEquippedCast(from) || Attributes.SpellChanneling != 0;
base.AllowEquippedCast(from) || Attributes.SpellChanneling != 0 || EnchantSpell.ProvidesSpellChanneling(this, from);
public virtual int GetLuckBonus() => CraftResources.GetInfo(_resource)?.AttributeInfo?.WeaponLuck ?? 0;

View file

@ -736,6 +736,8 @@ namespace Server.Spells
fc -= EssenceOfWindSpell.GetFCMalus(Caster);
}
fc += Mysticism.EnchantSpell.GetFasterCasting(Caster);
if (Core.SA)
{
// At some point OSI added 0.25s to every spell. This makes the minimum 0.5s

View file

@ -184,7 +184,7 @@ namespace Server.Spells
Register(677, typeof(NetherBoltSpell));
Register(678, typeof(HealingStoneSpell));
Register(679, typeof(PurgeMagicSpell));
// Register(680, typeof(EnchantSpell));
Register(680, typeof(EnchantSpell));
// Register(681, typeof(SleepSpell));
Register(682, typeof(EagleStrikeSpell));
Register(683, typeof(AnimatedWeaponSpell));

View file

@ -0,0 +1,386 @@
using System;
using System.Collections.Generic;
using ModernUO.CodeGeneratedEvents;
using Server.Engines.BuffIcons;
using Server.Gumps;
using Server.Items;
using Server.Mobiles;
using Server.Spells.Ninjitsu;
using Server.Spells.Spellweaving;
namespace Server.Spells.Mysticism;
public class EnchantSpell : MysticSpell
{
private const int MaxHitSpellChance = 60;
private const double SpellChannelingSkillThreshold = 80.0;
private static readonly SpellInfo _info = new(
"Enchant",
"In Ort Ylem",
230,
9022,
Reagent.SpidersSilk,
Reagent.MandrakeRoot,
Reagent.SulfurousAsh
);
private static readonly AosWeaponAttribute[] _hitSpellAttributes =
[
AosWeaponAttribute.HitMagicArrow,
AosWeaponAttribute.HitHarm,
AosWeaponAttribute.HitFireball,
AosWeaponAttribute.HitLightning,
AosWeaponAttribute.HitDispel
];
private static readonly Dictionary<BaseWeapon, EnchantmentTimer> _table = new();
private static bool _configured;
private TimerExecutionToken _selectionTimer;
public EnchantSpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info)
{
}
public override SpellCircle Circle => SpellCircle.Second;
public static void Configure()
{
if (_configured)
{
return;
}
_configured = true;
EventSink.Logout += OnLogout;
}
public override bool ClearHandsOnCast => false;
public override bool CheckCast()
{
if (!base.CheckCast())
{
return false;
}
if (Caster.Weapon is not BaseWeapon weapon || weapon is Fists)
{
Caster.SendLocalizedMessage(501078); // You must be holding a weapon.
return false;
}
return ValidateWeapon(weapon);
}
public override void OnCast()
{
if (Caster.Weapon is not BaseWeapon weapon || weapon is Fists)
{
Caster.SendLocalizedMessage(501078); // You must be holding a weapon.
FinishSequence();
}
else if (!ValidateWeapon(weapon))
{
FinishSequence();
}
else
{
EnchantGump.DisplayTo(Caster, this, weapon);
Timer.StartTimer(TimeSpan.FromSeconds(30), CancelSelection, out _selectionTimer);
}
}
public override void OnDisturb(DisturbType type, bool message)
{
Caster.CloseGump<EnchantGump>();
_selectionTimer.Cancel();
base.OnDisturb(type, message);
}
public override void FinishSequence()
{
_selectionTimer.Cancel();
Caster.CloseGump<EnchantGump>();
base.FinishSequence();
}
internal void FinishSelection(BaseWeapon weapon, AosWeaponAttribute attribute)
{
if (Caster.Spell != this || State != SpellState.Sequencing)
{
return;
}
try
{
if (Caster.Weapon != weapon || weapon.Deleted || weapon is Fists)
{
Caster.SendLocalizedMessage(501078); // You must be holding a weapon.
return;
}
if (!ValidateWeapon(weapon))
{
return;
}
if (!CheckSequence())
{
return;
}
var value = GetHitSpellChance(Caster);
var duration = GetDuration(attribute);
var grantsAdvancedEffects =
Caster.Skills.Mysticism.Value >= SpellChannelingSkillThreshold &&
Math.Max(Caster.Skills.Imbuing.Value, Caster.Skills.Focus.Value) >= SpellChannelingSkillThreshold;
var grantsSpellChanneling = grantsAdvancedEffects && weapon.Attributes.SpellChanneling == 0;
var timer = new EnchantmentTimer(
Caster,
weapon,
attribute,
value,
grantsSpellChanneling,
grantsAdvancedEffects,
duration
);
_table[weapon] = timer;
timer.Start();
Caster.PlaySound(0x64E);
Caster.FixedEffect(0x36CB, 1, 9, 1915, 0);
weapon.InvalidateProperties();
if (Caster is PlayerMobile player)
{
player.AddBuff(
new BuffInfo(
BuffIcon.Enchant,
1080126,
GetBuffCliloc(attribute),
duration,
$"{Caster.Name}\t{value}"
)
);
}
}
finally
{
FinishSequence();
}
}
internal void CancelSelection()
{
if (Caster.Spell == this && State == SpellState.Sequencing)
{
Caster.SendLocalizedMessage(1080132); // You decide not to enchant your weapon.
FinishSequence();
}
else
{
_selectionTimer.Cancel();
Caster.CloseGump<EnchantGump>();
}
}
internal static int GetHitSpellChance(Mobile caster)
{
var skillTotal = GetBaseSkill(caster) + Math.Max(caster.Skills.Imbuing.Value, caster.Skills.Focus.Value);
return Math.Clamp((int)(MaxHitSpellChance * skillTotal / 240.0), 0, MaxHitSpellChance);
}
internal static TimeSpan GetDuration(AosWeaponAttribute attribute) =>
attribute switch
{
// Publish 65 states that duration scales with the selected hit spell's level.
// The issue does not provide an official per-level table, so keep the chosen
// deterministic current-SA policy explicit and capped at the 150-second maximum.
AosWeaponAttribute.HitMagicArrow => TimeSpan.FromSeconds(30),
AosWeaponAttribute.HitHarm => TimeSpan.FromSeconds(60),
AosWeaponAttribute.HitFireball => TimeSpan.FromSeconds(90),
AosWeaponAttribute.HitLightning => TimeSpan.FromSeconds(120),
AosWeaponAttribute.HitDispel => TimeSpan.FromSeconds(150),
_ => TimeSpan.Zero
};
internal static int GetHitSpellBonus(BaseWeapon weapon, AosWeaponAttribute attribute) =>
_table.TryGetValue(weapon, out var timer) && timer.Attribute == attribute ? timer.Value : 0;
internal static bool ProvidesSpellChanneling(BaseWeapon weapon, Mobile caster) =>
_table.TryGetValue(weapon, out var timer) && timer.Caster == caster && timer.Weapon.Parent == caster &&
timer.GrantsSpellChanneling;
internal static int GetFasterCasting(Mobile caster)
{
foreach (var timer in _table.Values)
{
if (timer.Caster == caster && timer.Weapon?.Deleted == false && timer.Weapon.Parent == caster)
{
return timer.GrantsFasterCasting ? -1 : 0;
}
}
return 0;
}
public static bool IsEnchanted(BaseWeapon weapon) => weapon != null && _table.ContainsKey(weapon);
public static void StopEffect(BaseWeapon weapon) => StopEffect(weapon, false);
internal static void ExpireForTests(BaseWeapon weapon) => StopEffect(weapon, true);
private static void StopEffect(BaseWeapon weapon, bool expired)
{
if (weapon == null || !_table.Remove(weapon, out var timer))
{
return;
}
timer.Stop();
var caster = timer.Caster;
if (expired && caster?.Deleted == false)
{
caster.SendLocalizedMessage(1115273); // The enchantment on your weapon has expired.
caster.PlaySound(0x1E6);
}
(caster as PlayerMobile)?.RemoveBuff(BuffIcon.Enchant);
timer.ClearReferences();
if (!weapon.Deleted)
{
weapon.InvalidateProperties();
}
}
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
[OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))]
[OnEvent(nameof(BaseCreature.CreatureDeathEvent))]
[OnEvent(nameof(BaseCreature.CreatureDeletedEvent))]
public static void OnCasterRemoved(Mobile caster)
{
var weapons = new List<BaseWeapon>();
foreach (var pair in _table)
{
if (pair.Value.Caster == caster)
{
weapons.Add(pair.Key);
}
}
for (var i = 0; i < weapons.Count; i++)
{
StopEffect(weapons[i]);
}
}
private static void OnLogout(Mobile caster) => OnCasterRemoved(caster);
private static EnchantmentTimer FindEffect(Mobile caster)
{
foreach (var timer in _table.Values)
{
if (timer.Caster == caster)
{
return timer;
}
}
return null;
}
private bool ValidateWeapon(BaseWeapon weapon)
{
if (weapon == null || weapon.Deleted || weapon is Fists)
{
return false;
}
if (FindEffect(Caster) != null || _table.ContainsKey(weapon))
{
Caster.SendLocalizedMessage(501775); // You already have an enchantment on a weapon.
return false;
}
if (weapon.Cursed || weapon.Consecrated || ImmolatingWeaponSpell.IsImmolating(weapon))
{
Caster.SendLocalizedMessage(1080128); // You cannot enchant that weapon.
return false;
}
if (weapon.Parent is Mobile wielder && SpecialMove.GetCurrentMove(wielder) is FocusAttack)
{
Caster.SendLocalizedMessage(1080446); // You cannot enchant your weapon while using Focus Attack.
return false;
}
for (var i = 0; i < _hitSpellAttributes.Length; i++)
{
if (weapon.WeaponAttributes[_hitSpellAttributes[i]] > 0)
{
Caster.SendLocalizedMessage(1080127); // That weapon already has a hit spell.
return false;
}
}
return true;
}
private static int GetBuffCliloc(AosWeaponAttribute attribute) =>
attribute switch
{
AosWeaponAttribute.HitLightning => 1060423,
AosWeaponAttribute.HitFireball => 1060420,
AosWeaponAttribute.HitHarm => 1060421,
AosWeaponAttribute.HitMagicArrow => 1060426,
AosWeaponAttribute.HitDispel => 1060417,
_ => 0
};
private sealed class EnchantmentTimer : Timer
{
public EnchantmentTimer(
Mobile caster,
BaseWeapon weapon,
AosWeaponAttribute attribute,
int value,
bool grantsSpellChanneling,
bool grantsFasterCasting,
TimeSpan duration
) : base(duration)
{
Caster = caster;
Weapon = weapon;
Attribute = attribute;
Value = value;
GrantsSpellChanneling = grantsSpellChanneling;
GrantsFasterCasting = grantsFasterCasting;
}
public Mobile Caster { get; private set; }
public BaseWeapon Weapon { get; private set; }
public AosWeaponAttribute Attribute { get; }
public int Value { get; }
public bool GrantsSpellChanneling { get; }
public bool GrantsFasterCasting { get; }
protected override void OnTick() => StopEffect(Weapon, true);
public void ClearReferences()
{
Caster = null;
Weapon = null;
}
}
}