From 95177fb64761095a634457128a57280bd9c140b0 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:01:40 -0700 Subject: [PATCH] fix: a deleted character's followers, and familiars that never unregistered (#2675) ## Summary **Followers of a deleted character.** `PlayerMobile.OnDelete` deleted stabled pets and left every active follower pointing at the deleted mobile. Pets stayed controlled by nobody, and after a restart they loaded as owned pets with no owner. Now, when a character is deleted: - bonded or dead pets and summons (controlled or not) are deleted; - unbonded pets are released: they go wild and despawn on the abandoned-pet timer; - escorts and hirelings are left alone, because they already notice a deleted master and walk off on their own. **Familiars and animated dead that never unregistered.** `BaseCreature` unregistered them in `OnAfterDelete`, after `OnDelete` had already cleared the master. Both calls did nothing, so `SummonFamiliarSpell.Table` and the animated-dead registry kept deleted creatures until the caster's next cast. The unregisters now run in `OnDelete`, before the master is cleared. ## Test plan - [x] `DeletedMasterTests` (6 cases, all failing on main): unbonded pet released; bonded and bonded-dead pets deleted; controlled and uncontrolled summons deleted; familiar unregistered on delete - [x] UOContent.Tests (1184 passed, 2 skipped) and Server.Tests (907) green --- .../Tests/Mobiles/DeletedMasterTests.cs | 114 ++++++++++++++++++ Projects/UOContent/Mobiles/BaseCreature.cs | 21 ++-- Projects/UOContent/Mobiles/PlayerMobile.cs | 29 +++++ 3 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs 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)