diff --git a/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs new file mode 100644 index 000000000..f01e5c7bd --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Server.Spells.Necromancy; +using Xunit; + +namespace UOContent.Tests.Mobiles; + +// A deleted master must not leave creatures pointing at it. +[Collection("Sequential UOContent Tests")] +public class DeletedMasterTests : IDisposable +{ + private readonly List _created = []; + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private sealed class Stub : BaseCreature + { + public Stub() : base(AIType.AI_Melee) => Body = 0xC9; + + public Stub(Serial serial) : base(serial) + { + } + } + + private Stub NewCreature() + { + var bc = new Stub(); + _created.Add(bc); + return bc; + } + + private PlayerMobile NewPlayer() + { + var pm = new PlayerMobile(World.NewMobile); + pm.DefaultMobileInit(); + World.AddEntity(pm); + _created.Add(pm); + return pm; + } + + [Fact] + public void DeletingAPlayer_ReleasesAnUnbondedPet() + { + var owner = NewPlayer(); + var pet = NewCreature(); + pet.SetControlMaster(owner); + + owner.Delete(); + + Assert.False(pet.Deleted); + Assert.False(pet.Controlled); + Assert.Null(pet.Master); + Assert.True(pet.PendingDeleteTimer?.Running); + } + + [Theory] + [InlineData(false)] // bonded + [InlineData(true)] // bonded and dead + public void DeletingAPlayer_DeletesABondedPet(bool dead) + { + var owner = NewPlayer(); + var pet = NewCreature(); + pet.SetControlMaster(owner); + pet.IsBonded = true; + pet.IsDeadPet = dead; + + owner.Delete(); + + Assert.True(pet.Deleted); + } + + [Theory] + [InlineData(false)] // energy vortex + [InlineData(true)] // controlled summon + public void DeletingAPlayer_DeletesItsSummons(bool controlled) + { + var caster = NewPlayer(); + var summon = NewCreature(); + + if (controlled) + { + summon.SetControlMaster(caster); + } + + summon.Summoned = true; + summon.Master = caster; + + caster.Delete(); + + Assert.True(summon.Deleted); + } + + [Fact] + public void DeletingAFamiliar_UnregistersIt() + { + var caster = NewPlayer(); + var familiar = NewCreature(); + familiar.SetControlMaster(caster); + familiar.Summoned = true; + SummonFamiliarSpell.Table[caster] = familiar; + + familiar.Delete(); + + Assert.False(SummonFamiliarSpell.Table.ContainsKey(caster)); + } +} diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index e1b5dbdcf..f0210d4f6 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -2717,16 +2717,6 @@ namespace Server.Mobiles FocusMob = null; - if (IsAnimatedDead) - { - AnimateDeadSpell.Unregister(SummonMaster, this); - } - - if (SummonMaster != null) - { - SummonFamiliarSpell.Unregister(SummonMaster, this); - } - if (MLQuestSystem.Enabled) { MLQuestSystem.HandleDeletion(this); @@ -3683,6 +3673,17 @@ namespace Server.Mobiles { CreatureEvents.CreatureDeletedEvent(this); + // Both registries are keyed by the master, which is cleared below. + if (IsAnimatedDead) + { + AnimateDeadSpell.Unregister(SummonMaster, this); + } + + if (SummonMaster != null) + { + SummonFamiliarSpell.Unregister(SummonMaster, this); + } + var m = ControlMaster; SetControlMaster(null); diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index f1a81a53a..a138371ba 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -3835,6 +3835,35 @@ namespace Server.Mobiles Stabled = null; } + + if (_allFollowers?.Count > 0) + { + // Releasing or deleting a follower removes it from _allFollowers. + using var followers = PooledRefQueue.Create(_allFollowers.Count); + + foreach (var follower in _allFollowers) + { + // Escorts and hirelings notice a deleted master and walk off on their own. + if (follower is BaseCreature bc and not (BaseEscortable or BaseHire)) + { + followers.Enqueue(bc); + } + } + + while (followers.Count > 0) + { + var bc = followers.Dequeue(); + if (bc.Summoned || bc.IsBonded || bc.IsDeadPet) + { + bc.Delete(); + } + else + { + // An unbonded pet goes wild and despawns on the abandoned-pet timer. + bc.ControlOrder = OrderType.Release; + } + } + } } public override int ComputeMovementSpeed(Direction dir, bool checkTurning = true)