From ab738d90af4cbaf4060942725e71e44e65fb1d60 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:12:29 -0700 Subject: [PATCH] fix: pet release never finished, summon master follows the pet, horse breeder and notoriety guards (#2613) Pet and summon bugs found by the master-reference audit for #2592 (comments there have the full inventory). Independent of delta saves. ## Releasing a pet never finished The Release order ran two half-releases that never met: - The order handler cleared the master. That set `Controlled` to false, so `Obey` never ran again and the think-side `DoOrderRelease` (re-home, three-day delete timer, backpack drop) was dead code: a released pet kept its pack and never despawned. - The loyalty drain called `DoOrderRelease` directly, so a pet whose loyalty hit zero got the countdown and dropped its pack but kept its master and its owner's follower slots, the opposite of the code comment's intent. `DoOrderRelease` is now the whole release (targets, bonding, `SetControlMaster(null)`, re-home, delete or countdown, pack drop) and runs once, synchronously, from the handler or the loyalty drain. Summons still die on release, as before. Two tests pin both entry points: master cleared, follower slots returned, countdown running, home anchored. This is the one place the `PetOrders` / `PetOrderHandlers` split bit; the wider audit of that duplication is a separate task. ## Summon master follows the pet Transfer, stable claim, GM "obey" and Ball of Summoning copied `SummonMaster` only when `Summoned`, so a talisman summon (`Summoned` is false, `SummonMaster` set) kept its original summoner after a transfer: two different masters on one creature, with the original summoner's area spells still exempting it. They now mirror the summon master whenever it is set. Jail stabling cleared only `ControlMaster`, so a jailed talisman summon kept charging the summoner's follower slots; it now clears both, like the stable master and auto-stable already do. ## Small ones - The faction horse breeder set `Controlled`/`ControlMaster` directly; it now goes through `SetControlMaster` and tells the buyer why it refused (1049607) instead of silently deleting the horse. - `Notoriety` dereferenced `SummonMaster` on a summoned creature without a null check. ## Tests UOContent.Tests 778 green (two new). --- .../Tests/Mobiles/AI/PetOrderTests.cs | 32 +++++++++++++++++++ .../Factions/Gumps/HorseBreederGump.cs | 6 ++-- .../Special/Solen Items/BallOfSummoning.cs | 2 +- Projects/UOContent/Misc/Notoriety.cs | 2 +- .../UOContent/Mobiles/AI/BaseAI/OnSpeech.cs | 2 +- .../Mobiles/AI/BaseAI/PetOrderHandlers.cs | 9 +----- .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 15 +++++++++ .../Mobiles/AI/BaseAI/TransferItem.cs | 2 +- Projects/UOContent/Mobiles/BaseCreature.cs | 7 ---- Projects/UOContent/Mobiles/PlayerMobile.cs | 2 +- .../Mobiles/Vendors/NPC/AnimalTrainer.cs | 2 +- .../Systems/JailSystem/JailSystem.cs | 1 + 12 files changed, 57 insertions(+), 25 deletions(-) diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs index e0fe22a39..e5d7760ed 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs @@ -176,6 +176,38 @@ public class PetOrderTests : IDisposable Assert.Equal(Direction.North, pet.Direction); // frozen -> no wander attempts } + [Fact] + public void ReleaseOrder_ClearsTheMaster_AndStartsTheDeleteCountdown() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.IsBonded = true; + var followers = master.Followers; + + pet.ControlOrder = OrderType.Release; + + Assert.False(pet.Controlled); + Assert.Null(pet.ControlMaster); + Assert.False(pet.IsBonded); + Assert.Equal(followers - pet.ControlSlots, master.Followers); + Assert.True(pet.PendingDeleteTimer?.Running); + Assert.Equal(pet.Location, pet.Home); + } + + [Fact] + public void LoyaltyRelease_ClearsTheMaster_AndStartsTheDeleteCountdown() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var followers = master.Followers; + + // What the loyalty drain calls when loyalty reaches zero. + pet.AIObject.DoOrderRelease(); + + Assert.False(pet.Controlled); + Assert.Null(pet.ControlMaster); + Assert.Equal(followers - pet.ControlSlots, master.Followers); + Assert.True(pet.PendingDeleteTimer?.Running); + } + [Fact] public void Release_WithoutSpawner_AnchorsHomeToCurrentLocation() { diff --git a/Projects/UOContent/Engines/Factions/Gumps/HorseBreederGump.cs b/Projects/UOContent/Engines/Factions/Gumps/HorseBreederGump.cs index 7cd4edc92..8e8bf9308 100644 --- a/Projects/UOContent/Engines/Factions/Gumps/HorseBreederGump.cs +++ b/Projects/UOContent/Engines/Factions/Gumps/HorseBreederGump.cs @@ -61,7 +61,7 @@ public class HorseBreederGump : FactionGump if (m_From.Followers + horse.ControlSlots > m_From.FollowersMax) { - // TODO: Message? + m_From.SendLocalizedMessage(1049607); // You have too many followers to control that creature. horse.Delete(); } else @@ -79,9 +79,7 @@ public class HorseBreederGump : FactionGump else if (pack.ConsumeTotal(typeof(Silver), FactionWarHorse.SilverPrice) && pack.ConsumeTotal(typeof(Gold), FactionWarHorse.GoldPrice)) { - horse.Controlled = true; - horse.ControlMaster = m_From; - + horse.SetControlMaster(m_From); horse.ControlOrder = OrderType.Follow; horse.ControlTarget = m_From; diff --git a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs index 070a802b1..bf0953041 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs @@ -228,7 +228,7 @@ public partial class BallOfSummoning : Item, TranslocationItem { pet.SetControlMaster(from); - if (pet.Summoned) + if (pet.SummonMaster != null) { pet.SummonMaster = from; } diff --git a/Projects/UOContent/Misc/Notoriety.cs b/Projects/UOContent/Misc/Notoriety.cs index cb9b1ddcb..4416b09b5 100644 --- a/Projects/UOContent/Misc/Notoriety.cs +++ b/Projects/UOContent/Misc/Notoriety.cs @@ -228,7 +228,7 @@ namespace Server.Misc } if (bcTarg?.Controlled == true - || bcTarg?.Summoned == true && bcTarg.SummonMaster != from && bcTarg.SummonMaster.Player) + || bcTarg?.Summoned == true && bcTarg.SummonMaster != from && bcTarg.SummonMaster?.Player == true) { return false; // Cannot harm other controlled mobiles from players } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs index 8ffd39b42..ce9af5bb7 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs @@ -448,7 +448,7 @@ public abstract partial class BaseAI { Mobile.SetControlMaster(e.Mobile); - if (Mobile.Summoned) + if (Mobile.SummonMaster != null) { Mobile.SummonMaster = e.Mobile; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs index 862c78649..a132bc896 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs @@ -261,15 +261,8 @@ public abstract partial class BaseAI } _commandIssuer?.RevealingAction(); - Mobile.ControlTarget = null; - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; Mobile.PlaySound(Mobile.GetIdleSound()); - Mobile.BondingBegin = DateTime.MinValue; - Mobile.OwnerAbandonTime = DateTime.MinValue; - Mobile.IsBonded = false; - Mobile.SetControlMaster(null); + DoOrderRelease(); _commandIssuer = null; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index e6c48850d..e09424328 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -13,6 +13,8 @@ * along with this program. If not, see . * ************************************************************************/ +using System; + namespace Server.Mobiles; public abstract partial class BaseAI @@ -457,10 +459,23 @@ public abstract partial class BaseAI return best; } + /// + /// The whole release: the master is cleared here, so this runs once, synchronously, from + /// the Release order handler or the loyalty drain, never from Obey. + /// public virtual bool DoOrderRelease() { DebugSay("I have been released to the wild."); + Mobile.ControlTarget = null; + Mobile.FocusMob = null; + Mobile.Warmode = false; + Mobile.Combatant = null; + Mobile.BondingBegin = DateTime.MinValue; + Mobile.OwnerAbandonTime = DateTime.MinValue; + Mobile.IsBonded = false; + Mobile.SetControlMaster(null); + var spawner = Mobile.Spawner; if (spawner != null) diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs index 9efad5a5e..331df533c 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs @@ -156,7 +156,7 @@ internal sealed partial class TransferItem : Item private void TransferPetOwnership(Mobile from, Mobile to) { - if (_creature.Summoned) + if (_creature.SummonMaster != null) { _creature.SummonMaster = to; } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 5ea68cb13..f8dba7bf1 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -5923,14 +5923,7 @@ namespace Server.Mobiles c.Say(1043255, c.Name); // ~1_NAME~ appears to have decided that is better off without a master! c.Loyalty = BaseCreature.MaxLoyalty; - c.IsBonded = false; - c.BondingBegin = DateTime.MinValue; - c.OwnerAbandonTime = DateTime.MinValue; - c.ControlTarget = null; - // Release directly: a creature left alone with its AI disabled would - // otherwise never release and permanently hold its owner's follower slots. c.AIObject.DoOrderRelease(); - c.DropBackpack(); } while (toRemove.Count > 0) diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index d0b1b2c7a..39be0e10a 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -3602,7 +3602,7 @@ namespace Server.Mobiles { pet.SetControlMaster(this); - if (pet.Summoned) + if (pet.SummonMaster != null) { pet.SummonMaster = this; } diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs b/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs index 847209869..b3fb4d721 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs @@ -357,7 +357,7 @@ namespace Server.Mobiles { pet.SetControlMaster(from); - if (pet.Summoned) + if (pet.SummonMaster != null) { pet.SummonMaster = from; } diff --git a/Projects/UOContent/Systems/JailSystem/JailSystem.cs b/Projects/UOContent/Systems/JailSystem/JailSystem.cs index 8802d0487..8da7319c9 100644 --- a/Projects/UOContent/Systems/JailSystem/JailSystem.cs +++ b/Projects/UOContent/Systems/JailSystem/JailSystem.cs @@ -144,6 +144,7 @@ public class JailSystem : GenericPersistence bc.Internalize(); bc.SetControlMaster(null); + bc.SummonMaster = null; bc.IsStabled = true; bc.StabledBy = from;