From 010dbf3683531cd581c80b26fa835229b0187db9 Mon Sep 17 00:00:00 2001 From: Crome696 Date: Thu, 9 Jul 2026 20:30:18 +0200 Subject: [PATCH] feat: add Dryad Allure spell --- .../Spellweaving/DryadAllureSpellTests.cs | 273 ++++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 9 + .../Mobiles/AI/BaseAI/TransferItem.cs | 2 +- Projects/UOContent/Mobiles/BaseCreature.cs | 42 ++- .../Mobiles/Vendors/NPC/AnimalTrainer.cs | 10 +- Projects/UOContent/Spells/Initializer.cs | 2 +- .../Spells/Spellweaving/DryadAllure.cs | 179 ++++++++++++ 7 files changed, 508 insertions(+), 9 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Spells/Spellweaving/DryadAllureSpellTests.cs create mode 100644 Projects/UOContent/Spells/Spellweaving/DryadAllure.cs diff --git a/Projects/UOContent.Tests/Tests/Spells/Spellweaving/DryadAllureSpellTests.cs b/Projects/UOContent.Tests/Tests/Spells/Spellweaving/DryadAllureSpellTests.cs new file mode 100644 index 000000000..9dacb0113 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Spells/Spellweaving/DryadAllureSpellTests.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Server.Items; +using Server.Mobiles; +using Server.Spells; +using Server.Spells.First; +using Server.Spells.Spellweaving; +using Xunit; + +namespace Server.Tests.Spells.Spellweaving; + +[Collection("Sequential UOContent Tests")] +public class DryadAllureSpellTests +{ + private static Mobile NewCaster(double spellweaving = 120.0) + { + var caster = new Mobile(World.NewMobile); + caster.DefaultMobileInit(); + caster.InitStats(100, 100, 100); + caster.Mana = 100; + caster.Skills.Spellweaving.Base = spellweaving; + return caster; + } + + [Fact] + public void SpellMetadata_MatchesDryadAllureSources() + { + var caster = NewCaster(); + var spell = new DryadAllureSpell(caster); + + Assert.Equal("Dryad Allure", spell.Name); + Assert.Equal("Rathril", spell.Mantra); + Assert.Equal(TimeSpan.FromSeconds(3.0), spell.CastDelayBase); + Assert.Equal(52.0, spell.RequiredSkill); + Assert.Equal(40, spell.GetMana()); + Assert.Equal(SkillName.Spellweaving, spell.CastSkill); + + caster.Delete(); + } + + [Fact] + public void SpellweavingBookAndScroll_UseDryadAllureSlot() + { + var book = new SpellweavingBook(1UL << (611 - 600)); + var scroll = new DryadAllureScroll(); + + Assert.Equal(600, book.BookOffset); + Assert.Equal(16, book.BookCount); + Assert.True(book.HasSpell(611)); + Assert.Equal(611, GetSpellScrollId(scroll)); + + book.Delete(); + scroll.Delete(); + } + + [Fact] + public void RegisterSpellweaving_PreMl_DoesNotExposeDryadAllure() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.SE; + + Initializer.Configure(); + + Assert.Null(SpellRegistry.NewSpell(611, caster, null)); + Assert.Equal(-1, SpellRegistry.GetRegistryNumber(typeof(DryadAllureSpell))); + Assert.IsType(SpellRegistry.NewSpell(4, caster, null)); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void RegisterSpellweaving_Ml_ExposesDryadAllureAtSpellId611() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.ML; + + Initializer.Configure(); + + Assert.Same(typeof(DryadAllureSpell), SpellRegistry.Types[611]); + Assert.Equal(611, SpellRegistry.GetRegistryNumber(typeof(DryadAllureSpell))); + Assert.IsType(SpellRegistry.NewSpell(611, caster, null)); + Assert.IsType(SpellRegistry.NewSpell(610, caster, null)); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void IsValidTarget_AllowsRepondHumanoidsAndRejectsUnsafeTargets() + { + var validOrc = new TestOrc(); + var controlledOrc = new TestOrc(); + var summonedOrc = new TestOrc { Summoned = true }; + var paragonOrc = new TestOrc { IsParagon = true }; + var immuneOrc = new AllureImmuneOrc(); + var nonRepond = new TestCreature(); + var master = NewCaster(); + + controlledOrc.SetControlMaster(master); + + Assert.True(DryadAllureSpell.IsValidTarget(validOrc)); + Assert.False(DryadAllureSpell.IsValidTarget(controlledOrc)); + Assert.False(DryadAllureSpell.IsValidTarget(summonedOrc)); + Assert.False(DryadAllureSpell.IsValidTarget(paragonOrc)); + Assert.False(DryadAllureSpell.IsValidTarget(immuneOrc)); + Assert.False(DryadAllureSpell.IsValidTarget(nonRepond)); + + master.Delete(); + validOrc.Delete(); + controlledOrc.Delete(); + summonedOrc.Delete(); + paragonOrc.Delete(); + immuneOrc.Delete(); + nonRepond.Delete(); + } + + [Fact] + public void GetCharmChance_IncludesSpellweavingSkillAndFocusBonus() + { + var noFocusChance = DryadAllureSpell.GetCharmChance(75.0, 0); + var focusChance = DryadAllureSpell.GetCharmChance(75.0, 3); + + Assert.Equal(0.5, noFocusChance, 3); + Assert.Equal(0.56, focusChance, 3); + } + + [Fact] + public void ApplyAllureAttempt_SuccessControlsTargetAsThreeSlotAlluredFollowerAndDeletesBackpackContents() + { + var caster = NewCaster(); + var target = new TestOrc(); + target.PackItem(new Gold(10)); + caster.Combatant = target; + caster.Warmode = true; + target.Combatant = caster; + target.Warmode = true; + + using var random = new PredictableRandom(0); + + Assert.True(DryadAllureSpell.ApplyAllureAttempt(caster, target, focusLevel: 0)); + Assert.True(target.Controlled); + Assert.Equal(caster, target.ControlMaster); + Assert.True(target.Allured); + Assert.Equal(3, target.ControlSlots); + Assert.Equal(3, caster.Followers); + Assert.Equal(BaseCreature.MaxLoyalty, target.Loyalty); + Assert.Null(caster.Combatant); + Assert.Null(target.Combatant); + Assert.False(caster.Warmode); + Assert.False(target.Warmode); + Assert.Empty(target.Backpack?.Items ?? []); + + caster.Delete(); + target.Delete(); + } + + [Fact] + public void ApplyAllureAttempt_FailureEnragesTargetWithoutFollowerLeak() + { + var caster = NewCaster(spellweaving: 52.0); + var target = new TestOrc(); + + using var random = new PredictableRandom(20); + + Assert.False(DryadAllureSpell.ApplyAllureAttempt(caster, target, focusLevel: 0)); + Assert.False(target.Controlled); + Assert.False(target.Allured); + Assert.Equal(caster, target.ControlTarget); + Assert.Equal(OrderType.Attack, target.ControlOrder); + Assert.Equal(caster, target.Combatant); + Assert.True(target.Warmode); + Assert.Equal(0, caster.Followers); + + caster.Delete(); + target.Delete(); + } + + [Fact] + public void SetControlMasterNull_ClearsAlluredStateOnRelease() + { + var caster = NewCaster(); + var target = new TestOrc(); + + target.ControlSlots = 3; + target.SetControlMaster(caster); + target.Allured = true; + + target.SetControlMaster(null); + + Assert.False(target.Controlled); + Assert.Null(target.ControlMaster); + Assert.False(target.Allured); + Assert.Equal(0, caster.Followers); + + caster.Delete(); + target.Delete(); + } + + 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)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 class TestOrc : Orc + { + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.2; + passiveSpeed = 0.4; + } + } + + private class AllureImmuneOrc : TestOrc + { + public override bool AllureImmune => true; + } + + private class TestCreature : BaseCreature + { + public TestCreature() : base(AIType.AI_Animal, FightMode.Closest, 10, 1) + { + Body = 0xC8; + } + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.2; + passiveSpeed = 0.4; + } + } +} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 1b7139a96..134442c87 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -497,6 +497,15 @@ public abstract partial class BaseAI { this.DebugSayFormatted($"Beginning transfer with {to.Name}"); + if (Mobile.Allured) + { + SendTransferRefusalMessages(from, to, 1043250, 1043251); + // 1043250: The pet refuses to be transferred because it will not obey you sufficiently.~3_BLANK~ + // 1043251: The pet will not accept you as a master because it does not trust ~2_NAME~.~3_BLANK~ + ResumePersistentOrder(); + return true; + } + var youngFrom = from is PlayerMobile mobile && mobile.Young; var youngTo = to is PlayerMobile playerMobile && playerMobile.Young; diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs index 9efad5a5e..b69ff2916 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs @@ -146,7 +146,7 @@ internal sealed partial class TransferItem : Item Delete(); - if (!accepted || !_creature.SetControlMaster(to)) + if (!accepted || _creature.Allured || !_creature.SetControlMaster(to)) { return; } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index af85a195a..489574816 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -251,6 +251,7 @@ namespace Server.Mobiles }; private bool _summoned; + private bool _allured; private bool m_bTamable; private int m_ColdResistance; @@ -494,6 +495,30 @@ namespace Server.Mobiles public virtual bool DeathAdderCharmable => false; + [CommandProperty(AccessLevel.GameMaster)] + public bool Allured + { + get => _allured; + set + { + if (_allured == value) + { + return; + } + + _allured = value; + + if (_allured) + { + Loyalty = MaxLoyalty; + } + + InvalidateProperties(); + } + } + + public virtual bool AllureImmune => BardImmune || IsInvulnerable; + // TODO: Find the pub 31 tweaks to the DispelDifficulty and apply them of course. // at this skill level we dispel 50% chance public virtual double DispelDifficulty => 0.0; @@ -1843,7 +1868,7 @@ namespace Server.Mobiles { base.Serialize(writer); - writer.Write(20); // version + writer.Write(21); // version writer.Write((int)m_CurrentAI); writer.Write((int)m_DefaultAI); @@ -1963,6 +1988,9 @@ namespace Server.Mobiles // Version 19 writer.Write(HomeMap); + + // Version 21 + writer.Write(_allured); } public override void Deserialize(IGenericReader reader) @@ -2166,6 +2194,11 @@ namespace Server.Mobiles HomeMap = reader.ReadMap(); } + if (version >= 21) + { + _allured = reader.ReadBool(); + } + if (version <= 14 && m_Paragon && Hue == 0x31) { Hue = Paragon.Hue; // Paragon hue fixed, should now be 0x501. @@ -3471,6 +3504,7 @@ namespace Server.Mobiles { if (m == null) { + Allured = false; ControlMaster = null; Controlled = false; ControlTarget = null; @@ -5621,6 +5655,12 @@ namespace Server.Mobiles { c.OwnerAbandonTime = DateTime.MinValue; + if (c.Allured) + { + c.Loyalty = BaseCreature.MaxLoyalty; + continue; + } + if (c.Map != Map.Internal) { c.Loyalty -= BaseCreature.MaxLoyalty / 10; diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs b/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs index 64ee7d45c..e11f14881 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs @@ -227,12 +227,10 @@ namespace Server.Mobiles { SayTo(from, 502673); // I can not stable summoned creatures. } - /* - else if (pet.Allured) - { - SayTo( from, 1048053 ); // You can't stable that! - } - */ + else if (pet.Allured) + { + SayTo(from, 1048053); // You can't stable that! + } else if (pet is PackLlama or PackHorse or Beetle && pet.Backpack?.Items.Count > 0) { SayTo(from, 1042563); // You need to unload your pet. diff --git a/Projects/UOContent/Spells/Initializer.cs b/Projects/UOContent/Spells/Initializer.cs index 8639b3597..4d16bd821 100644 --- a/Projects/UOContent/Spells/Initializer.cs +++ b/Projects/UOContent/Spells/Initializer.cs @@ -171,7 +171,7 @@ namespace Server.Spells Register(608, typeof(ReaperFormSpell)); // Register(609, typeof(WildfireSpell)); Register(610, typeof(EssenceOfWindSpell)); - // Register(611, typeof(DryadAllureSpell)); + Register(611, typeof(DryadAllureSpell)); Register(612, typeof(EtherealVoyageSpell)); Register(613, typeof(WordOfDeathSpell)); Register(614, typeof(GiftOfLifeSpell)); diff --git a/Projects/UOContent/Spells/Spellweaving/DryadAllure.cs b/Projects/UOContent/Spells/Spellweaving/DryadAllure.cs new file mode 100644 index 000000000..aa7533270 --- /dev/null +++ b/Projects/UOContent/Spells/Spellweaving/DryadAllure.cs @@ -0,0 +1,179 @@ +using System; +using Server.Items; +using Server.Mobiles; +using Server.Targeting; + +namespace Server.Spells.Spellweaving +{ + public class DryadAllureSpell : ArcanistSpell + { + private const int ControlSlotCost = 3; + + private static readonly SpellInfo _info = new( + "Dryad Allure", + "Rathril", + -1 + ); + + public DryadAllureSpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info) + { + } + + public override TimeSpan CastDelayBase => TimeSpan.FromSeconds(3.0); + public override double RequiredSkill => 52.0; + public override int RequiredMana => 40; + + public static bool IsValidTarget(BaseCreature creature) + { + if (creature == null || creature.IsParagon || creature.Summoned || creature.AllureImmune || + creature.Controlled && !creature.Allured) + { + return false; + } + + return SlayerGroup.GetEntryByName(SlayerName.Repond)?.Slays(creature) == true; + } + + internal static double GetCharmChance(double spellweaving, int focusLevel) => + spellweaving / 150.0 + focusLevel / 50.0; + + internal static bool ApplyAllureAttempt(Mobile caster, BaseCreature creature, int focusLevel) + { + if (GetCharmChance(caster.Skills.Spellweaving.Value, focusLevel) <= Utility.RandomDouble()) + { + EnrageTarget(caster, creature); + return false; + } + + var oldControlSlots = creature.ControlSlots; + + creature.ControlSlots = ControlSlotCost; + creature.Combatant = null; + creature.Warmode = false; + creature.RemoveAggressor(caster); + creature.RemoveAggressed(caster); + caster.RemoveAggressor(creature); + caster.RemoveAggressed(creature); + + if (caster.Combatant == creature) + { + caster.Combatant = null; + caster.Warmode = false; + } + + if (!creature.SetControlMaster(caster)) + { + creature.ControlSlots = oldControlSlots; + return false; + } + + creature.PlaySound(0x5C4); + creature.Allured = true; + creature.Loyalty = BaseCreature.MaxLoyalty; + DeleteBackpackContents(creature); + + caster.SendLocalizedMessage(1074377); // You allure the humanoid to follow and protect you. + return true; + } + + private static void EnrageTarget(Mobile caster, BaseCreature creature) + { + creature.PlaySound(0x5C5); + creature.ControlTarget = caster; + creature.ControlOrder = OrderType.Attack; + creature.Combatant = caster; + creature.Warmode = true; + + caster.SendLocalizedMessage(1074378); // The humanoid becomes enraged by your charming attempt and attacks you. + } + + private static void DeleteBackpackContents(BaseCreature creature) + { + var pack = creature.Backpack; + + if (pack == null) + { + return; + } + + for (var i = pack.Items.Count - 1; i >= 0; --i) + { + if (i >= pack.Items.Count) + { + continue; + } + + pack.Items[i].Delete(); + } + } + + public void Target(BaseCreature creature) + { + if (creature == null || creature.Deleted) + { + Caster.SendLocalizedMessage(1074379); // You cannot charm that! + return; + } + + if (!Caster.CanSee(creature.Location) || !Caster.InLOS(creature)) + { + Caster.SendLocalizedMessage(500237); // Target can not be seen. + return; + } + + if (!IsValidTarget(creature)) + { + Caster.SendLocalizedMessage(1074379); // You cannot charm that! + return; + } + + if (creature.Allured) + { + Caster.SendLocalizedMessage(1074380); // This humanoid is already controlled by someone else. + return; + } + + if (Caster.Followers + ControlSlotCost > Caster.FollowersMax) + { + Caster.SendLocalizedMessage(1049607); // You have too many followers to control that creature. + return; + } + + if (CheckSequence()) + { + SpellHelper.Turn(Caster, creature); + ApplyAllureAttempt(Caster, creature, FocusLevel); + } + } + + public override void OnCast() + { + Caster.Target = new InternalTarget(this); + } + + private class InternalTarget : Target + { + private readonly DryadAllureSpell _spell; + + public InternalTarget(DryadAllureSpell spell) : base(12, false, TargetFlags.None) => + _spell = spell; + + protected override void OnTarget(Mobile from, object targeted) + { + if (targeted is BaseCreature creature) + { + _spell.Target(creature); + } + else + { + from.SendLocalizedMessage(1074379); // You cannot charm that! + } + } + + protected override void OnTargetFinish(Mobile from) + { + _spell.FinishSequence(); + } + } + } +}