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
This commit is contained in:
Kamron Batman 2026-09-24 23:01:40 -07:00 • committed by GitHub
parent 843a29e7dc
commit 95177fb647
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 154 additions and 10 deletions

View file

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

View file

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

View file

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