diff --git a/Projects/UOContent/Engines/ConPVP/DuelContext.cs b/Projects/UOContent/Engines/ConPVP/DuelContext.cs index f30e89bcf..b5078ed56 100644 --- a/Projects/UOContent/Engines/ConPVP/DuelContext.cs +++ b/Projects/UOContent/Engines/ConPVP/DuelContext.cs @@ -12,6 +12,7 @@ using Server.Regions; using Server.Spells; using Server.Spells.Bushido; using Server.Spells.Chivalry; +using Server.Spells.First; using Server.Spells.Fourth; using Server.Spells.Necromancy; using Server.Spells.Ninjitsu; @@ -1834,6 +1835,12 @@ public partial class DuelContext if (!Core.AOS) { + if (!Core.UOR) + { + ReactiveArmorSpell.RemoveEffect(mob); + ProtectionSpell.RemoveT2AProtection(mob); + } + mob.MagicDamageAbsorb = 0; mob.MeleeDamageAbsorb = 0; ProtectionSpell.Registry.Remove(mob); diff --git a/Projects/UOContent/Items/Weapons/BaseMeleeWeapon.cs b/Projects/UOContent/Items/Weapons/BaseMeleeWeapon.cs index 4df9ca877..4be1c5035 100644 --- a/Projects/UOContent/Items/Weapons/BaseMeleeWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseMeleeWeapon.cs @@ -1,3 +1,4 @@ +using Server.Spells.First; using Server.Spells.Spellweaving; namespace Server.Items @@ -23,34 +24,7 @@ namespace Server.Items return damage; } - var absorb = defender.MeleeDamageAbsorb; - - if (absorb > 0) - { - if (absorb > damage) - { - var react = damage / 5; - - if (react <= 0) - { - react = 1; - } - - defender.MeleeDamageAbsorb -= damage; - damage = 0; - - attacker.Damage(react, defender); - - attacker.PlaySound(0x1F1); - attacker.FixedEffect(0x374A, 10, 16); - } - else - { - defender.MeleeDamageAbsorb = 0; - defender.SendLocalizedMessage(1005556); // Your reactive armor spell has been nullified. - DefensiveSpell.Nullify(defender); - } - } + ReactiveArmorSpell.HandleMeleeHit(attacker, defender, ref damage); return damage; } diff --git a/Projects/UOContent/Spells/Base/SpellHelper.cs b/Projects/UOContent/Spells/Base/SpellHelper.cs index ab1bb55c4..dc7e22f80 100644 --- a/Projects/UOContent/Spells/Base/SpellHelper.cs +++ b/Projects/UOContent/Spells/Base/SpellHelper.cs @@ -893,16 +893,25 @@ namespace Server.Spells if (target.MagicDamageAbsorb > 0) { - ++circle; - - target.MagicDamageAbsorb -= circle; - - // This order isn't very intuitive, but you have to nullify reflect before target gets switched - reflect = target.MagicDamageAbsorb >= 0; - if (target.MagicDamageAbsorb <= 0) + if (!Core.UOR) { + // T2A: single-use reflection, consumed immediately target.MagicDamageAbsorb = 0; - DefensiveSpell.Nullify(target); + reflect = true; + } + else + { + ++circle; + + target.MagicDamageAbsorb -= circle; + + // This order isn't very intuitive, but you have to nullify reflect before target gets switched + reflect = target.MagicDamageAbsorb >= 0; + if (target.MagicDamageAbsorb <= 0) + { + target.MagicDamageAbsorb = 0; + DefensiveSpell.Nullify(target); + } } } diff --git a/Projects/UOContent/Spells/Fifth/MagicReflect.cs b/Projects/UOContent/Spells/Fifth/MagicReflect.cs index 94f1fbb38..576094e88 100644 --- a/Projects/UOContent/Spells/Fifth/MagicReflect.cs +++ b/Projects/UOContent/Spells/Fifth/MagicReflect.cs @@ -38,6 +38,11 @@ namespace Server.Spells.Fifth return false; } + if (!Core.UOR) + { + return true; + } + if (!Caster.CanBeginAction()) { Caster.SendLocalizedMessage(1005385); // The spell will not adhere to you at this time. @@ -105,6 +110,20 @@ namespace Server.Spells.Fifth } } } + else if (!Core.UOR) + { + if (Caster.MagicDamageAbsorb > 0) + { + Caster.SendLocalizedMessage(1005559); // This spell is already in effect. + } + else if (CheckSequence()) + { + Caster.MagicDamageAbsorb = 1; + + Caster.FixedParticles(0x375A, 10, 15, 5037, EffectLayer.Waist); + Caster.PlaySound(0x1E9); + } + } else { if (Caster.MagicDamageAbsorb > 0) diff --git a/Projects/UOContent/Spells/First/ReactiveArmor.cs b/Projects/UOContent/Spells/First/ReactiveArmor.cs index 768273244..6f88490db 100644 --- a/Projects/UOContent/Spells/First/ReactiveArmor.cs +++ b/Projects/UOContent/Spells/First/ReactiveArmor.cs @@ -3,10 +3,11 @@ using System.Collections.Generic; using ModernUO.CodeGeneratedEvents; using Server.Engines.BuffIcons; using Server.Mobiles; +using Server.Targeting; namespace Server.Spells.First { - public class ReactiveArmorSpell : MagerySpell + public class ReactiveArmorSpell : MagerySpell, ITargetingSpell { private static readonly SpellInfo _info = new( "Reactive Armor", @@ -18,7 +19,8 @@ namespace Server.Spells.First Reagent.SulfurousAsh ); - private static readonly Dictionary _table = new(); + private static Dictionary _table; + private static Dictionary _t2aTable; public ReactiveArmorSpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info) { @@ -26,9 +28,81 @@ namespace Server.Spells.First public override SpellCircle Circle => SpellCircle.First; + public static bool HasEffect(Mobile m) => _t2aTable?.ContainsKey(m) == true; + + public static void RemoveEffect(Mobile m) + { + if (_t2aTable?.Remove(m, out var token) == true) + { + token.Cancel(); + } + } + + public static void HandleMeleeHit(Mobile attacker, Mobile defender, ref int damage) + { + if (!Core.UOR) + { + // T2A: percentage-based melee reflection + if (!HasEffect(defender)) + { + return; + } + + // Only reflect adjacent melee hits; guards are exempt + if (!defender.InRange(attacker, 1) || attacker is BaseGuard) + { + return; + } + + var magery = defender.Skills.Magery.Value; + var reflectPct = (int)(10 + magery / 4); // 10–35% + var reflected = damage * reflectPct / 100; + + if (reflected > 0) + { + damage -= reflected; + + // Sourceless damage — does not trigger attacker's own combat events + attacker.Damage(reflected); + } + + attacker.PlaySound(0x1F1); + attacker.FixedEffect(0x374A, 10, 16); + } + else + { + // UOR: damage absorption pool + var absorb = defender.MeleeDamageAbsorb; + + if (absorb <= 0) + { + return; + } + + if (absorb > damage) + { + var react = Math.Max(damage / 5, 1); + + defender.MeleeDamageAbsorb -= damage; + damage = 0; + + attacker.Damage(react, defender); + + attacker.PlaySound(0x1F1); + attacker.FixedEffect(0x374A, 10, 16); + } + else + { + defender.MeleeDamageAbsorb = 0; + defender.SendLocalizedMessage(1005556); // Your reactive armor spell has been nullified. + DefensiveSpell.Nullify(defender); + } + } + } + public override bool CheckCast() { - if (Core.AOS) + if (Core.AOS || !Core.UOR) { return true; } @@ -48,6 +122,38 @@ namespace Server.Spells.First return true; } + public void Target(Mobile m) + { + if (!Caster.CanBeBeneficial(m)) + { + return; + } + + if (HasEffect(m)) + { + // This target already has Reactive Armor + Caster.SendLocalizedMessage(1005559); // This spell is already in effect. + return; + } + + if (CheckBSequence(m)) + { + Caster.DoBeneficial(m); + SpellHelper.Turn(Caster, m); + + m.FixedParticles(0x376A, 9, 32, 5008, EffectLayer.Waist); + m.PlaySound(0x1F2); + + var duration = TimeSpan.FromSeconds(25 + Caster.Skills.Magery.Value / 2); // 25–75s + + Timer.StartTimer(duration, () => ExpireT2AEffect(m), out var token); + _t2aTable ??= []; + _t2aTable[m] = token; + } + } + + private static void ExpireT2AEffect(Mobile m) => _t2aTable?.Remove(m); + public override void OnCast() { if (Core.AOS) @@ -63,7 +169,7 @@ namespace Server.Spells.First if (CheckSequence()) { - if (_table.Remove(Caster, out var mods)) + if (_table?.Remove(Caster, out var mods) == true) { Caster.PlaySound(0x1ED); Caster.FixedParticles(0x376A, 9, 32, 5008, EffectLayer.Waist); @@ -93,6 +199,7 @@ namespace Server.Spells.First new ResistanceMod(ResistanceType.Energy, "EnergyResistReactiveArmorSpell", -5) ]; + _table ??= []; _table[Caster] = mods; for (var i = 0; i < mods.Length; ++i) @@ -100,8 +207,7 @@ namespace Server.Spells.First Caster.AddResistanceMod(mods[i]); } - var physresist = 15 + (int)(Caster.Skills.Inscribe.Value / 20); - var args = $"{physresist}\t{5}\t{5}\t{5}\t{5}"; + var args = $"{15 + (int)(Caster.Skills.Inscribe.Value / 20)}\t{5}\t{5}\t{5}\t{5}"; (Caster as PlayerMobile)?.AddBuff(new BuffInfo(BuffIcon.ReactiveArmor, 1075812, 1075813, args: args)); } @@ -109,6 +215,10 @@ namespace Server.Spells.First FinishSequence(); } + else if (!Core.UOR) + { + Caster.Target = new SpellTarget(this, TargetFlags.Beneficial); + } else { if (Caster.MeleeDamageAbsorb > 0) @@ -148,7 +258,9 @@ namespace Server.Spells.First [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] public static void EndArmor(Mobile m) { - if (!_table.Remove(m, out var mods)) + RemoveEffect(m); + + if (_table?.Remove(m, out var mods) != true) { return; } diff --git a/Projects/UOContent/Spells/Fourth/ArchProtection.cs b/Projects/UOContent/Spells/Fourth/ArchProtection.cs index 18e2ab58b..cb44a7b25 100644 --- a/Projects/UOContent/Spells/Fourth/ArchProtection.cs +++ b/Projects/UOContent/Spells/Fourth/ArchProtection.cs @@ -69,6 +69,19 @@ namespace Server.Spells.Fourth } } } + else if (!Core.UOR) + { + while (targets.Count > 0) + { + var m = targets.Dequeue(); + if (!ProtectionSpell.HasT2AProtection(m)) + { + Caster.DoBeneficial(m); + ProtectionSpell.ApplyT2AProtection(Caster, m); + m.PlaySound(0x1F7); + } + } + } else { var val = (int)(Caster.Skills.Magery.Value / 10.0 + 1); diff --git a/Projects/UOContent/Spells/Second/Protection.cs b/Projects/UOContent/Spells/Second/Protection.cs index 424900fba..dcbeb998d 100644 --- a/Projects/UOContent/Spells/Second/Protection.cs +++ b/Projects/UOContent/Spells/Second/Protection.cs @@ -3,10 +3,11 @@ using System.Collections.Generic; using ModernUO.CodeGeneratedEvents; using Server.Engines.BuffIcons; using Server.Mobiles; +using Server.Targeting; namespace Server.Spells.Second { - public class ProtectionSpell : MagerySpell + public class ProtectionSpell : MagerySpell, ITargetingSpell { private static readonly SpellInfo _info = new( "Protection", @@ -18,8 +19,10 @@ namespace Server.Spells.Second Reagent.SulfurousAsh ); - // TODO: Cleanup periodically if players have logged out for a while - private static readonly Dictionary> _table = new(); + private static Dictionary> _table; + + // T2A: stores the AR bonus per mobile (shared with ArchProtection) + private static Dictionary _t2aTable; public ProtectionSpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info) { @@ -29,9 +32,39 @@ namespace Server.Spells.Second public override SpellCircle Circle => SpellCircle.Second; + public static bool HasT2AProtection(Mobile m) => _t2aTable?.ContainsKey(m) ?? false; + + public static void RemoveT2AProtection(Mobile m) + { + if (_t2aTable?.Remove(m, out var bonus) == true) + { + m.VirtualArmorMod -= Math.Min(bonus, m.VirtualArmorMod); + } + } + + public static void ApplyT2AProtection(Mobile caster, Mobile target) + { + if (HasT2AProtection(target)) + { + return; + } + + var magery = caster.Skills.Magery.Value; + var bonus = (int)(magery / 10); + var duration = TimeSpan.FromSeconds(6 * magery / 5); + + _t2aTable ??= []; + _t2aTable[target] = bonus; + target.VirtualArmorMod += bonus; + + target.FixedParticles(0x375A, 9, 20, 5016, EffectLayer.Waist); + + new InternalTimer(target, duration).Start(); + } + public override bool CheckCast() { - if (Core.AOS) + if (Core.AOS || !Core.UOR) { return true; } @@ -51,6 +84,28 @@ namespace Server.Spells.Second return false; } + public void Target(Mobile m) + { + if (!Caster.CanBeBeneficial(m)) + { + return; + } + + if (HasT2AProtection(m)) + { + Caster.SendLocalizedMessage(1005559); // This spell is already in effect. + return; + } + + if (CheckBSequence(m)) + { + Caster.DoBeneficial(m); + SpellHelper.Turn(Caster, m); + ApplyT2AProtection(Caster, m); + m.PlaySound(0x1ED); + } + } + // AOS+ only public static void Toggle(Mobile caster, Mobile target) { @@ -63,7 +118,7 @@ namespace Server.Spells.Second * even after dying, until you turn them off by casting them again. */ - if (_table.Remove(target, out var mods)) + if (_table?.Remove(target, out var mods) == true) { target.PlaySound(0x1ED); target.FixedParticles(0x375A, 9, 20, 5016, EffectLayer.Waist); @@ -85,6 +140,7 @@ namespace Server.Spells.Second var physMod = new ResistanceMod(ResistanceType.Physical, "PhysicalResistProtectionSpell", physLoss); var resistMod = new DefaultSkillMod(SkillName.MagicResist, "MagicResistProtectionSpell", true, resistLoss); + _table ??= []; _table[target] = Tuple.Create(physMod, resistMod); Registry[target] = 1000; // 100.0% protection from disruption @@ -99,7 +155,9 @@ namespace Server.Spells.Second [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] public static void EndProtection(Mobile m) { - if (!_table.Remove(m, out var mods)) + RemoveT2AProtection(m); + + if (_table?.Remove(m, out var mods) != true) { return; } @@ -123,6 +181,10 @@ namespace Server.Spells.Second FinishSequence(); } + else if (!Core.UOR) + { + Caster.Target = new SpellTarget(this, TargetFlags.Beneficial); + } else { if (Registry.ContainsKey(Caster)) @@ -142,7 +204,8 @@ namespace Server.Spells.Second Caster.Skills.Inscribe.Value) * 10 / 4; Registry.Add(Caster, Math.Clamp((int)value, 0, 750)); // 75.0% protection from disruption - new InternalTimer(Caster).Start(); + var duration = TimeSpan.FromSeconds(Math.Clamp(Caster.Skills.Magery.Value * 2.0, 15, 240)); + new InternalTimer(Caster, duration).Start(); Caster.FixedParticles(0x375A, 9, 20, 5016, EffectLayer.Waist); Caster.PlaySound(0x1ED); @@ -159,20 +222,21 @@ namespace Server.Spells.Second private class InternalTimer : Timer { - private readonly Mobile m_Caster; + private readonly Mobile _mobile; - public InternalTimer(Mobile caster) : base(TimeSpan.FromSeconds(0)) - { - var val = Math.Clamp(caster.Skills.Magery.Value * 2.0, 15, 240); - - m_Caster = caster; - Delay = TimeSpan.FromSeconds(val); - } + public InternalTimer(Mobile mobile, TimeSpan duration) : base(duration) => _mobile = mobile; protected override void OnTick() { - Registry.Remove(m_Caster); - DefensiveSpell.Nullify(m_Caster); + if (!Core.UOR) + { + RemoveT2AProtection(_mobile); + } + else + { + Registry.Remove(_mobile); + DefensiveSpell.Nullify(_mobile); + } } } }