feat: add Dryad Allure spell

This commit is contained in:
Crome696 2026-07-09 20:30:18 +02:00
parent 752bf3cc29
commit 010dbf3683
7 changed files with 508 additions and 9 deletions

View file

@ -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<MagicArrowSpell>(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<DryadAllureSpell>(SpellRegistry.NewSpell(611, caster, null));
Assert.IsType<EssenceOfWindSpell>(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<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 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;
}
}
}

View file

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

View file

@ -146,7 +146,7 @@ internal sealed partial class TransferItem : Item
Delete();
if (!accepted || !_creature.SetControlMaster(to))
if (!accepted || _creature.Allured || !_creature.SetControlMaster(to))
{
return;
}

View file

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

View file

@ -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.

View file

@ -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));

View file

@ -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();
}
}
}
}