From 114dbba6e25f0e97e8537e54025d7bfa87c03a39 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:51:10 -0700 Subject: [PATCH] fix: a stop order silently cancelled a standing follow order (#2616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The bug Pet is set to follow. It gets attacked and defends itself (the order flips to `Attack`), or the player tells it to `come`. The player then says `stop`. The pet quietly stops following and starts idle-wandering — the player has to re-issue the order to get it back. `ResolveStop` clears `ControlTarget`, since the transient order that is ending owns it, and then resumes the standing order. Nothing restores a target, so the resumed `Follow` has none. `DoOrderFollow` checks for a target before anything else, finds none on the very next think, says "I have no one to follow" and cancels the order to `None`: ``` follow standing order=Follow target=set persist=Follow cur=0.2 => move=0.3 attacked (defends) order=Attack target=set persist=Follow cur=0.2 => move=0.3 stop issued order=Follow target=null persist=Follow cur=0.2 => move=0.3 next think order=None target=null persist=Follow cur=0.4 => move=0.9 ``` Two things go wrong at once: - `PersistentOrder` still reads `Follow` while `ControlOrder` is `None`, so the pet never recovers on its own. - The pace falls from the active clock to the passive one. A shard that tunes `SetMoveSpeed(active, passive)` sees a following pet drop from its active move speed to its passive one for no visible reason — which is how this surfaced. Era-independent. Guard is unaffected: `DoOrderGuard` works off `ControlMaster`, not `ControlTarget`. ## The fix A standing `Follow` does not always mean "follow the master" — a pet friend can point it elsewhere with `all follow me` / `*follow me` (0x163, 0x16C) or `*follow` plus a target pick. So `SetPersistentOrder` remembers the target the standing order was given, and `ResumePersistentOrder` restores it, falling back to the master only when that target is gone. The field is runtime-only, like `PersistentOrder` itself. Doing this in `ResumePersistentOrder` rather than `ResolveStop` also covers the Friend/Unfriend/Transfer resumes, where `ControlTarget` points at a third party and the pet would otherwise have resumed its follow on *them*. It matches what `HandleInvalidControlTarget` already does before its own resume. ## Tests `Stop_WhileAttacking_FallsBackToPersistentFollow` already existed and passed, because it asserted the resumed order without checking for a target or driving a think tick. Three tests added alongside it: - `Stop_WhileAttacking_ResumedFollowTargetsTheMaster` - `Stop_WhileAttacking_ResumedFollowKeepsAPetFriendAsItsTarget` — a friend's follow order is not hijacked back to the owner - `Stop_WhileAttacking_ResumedFollowSurvivesTheNextThink` — the one that catches the cancellation Each was confirmed to fail without the change (`Expected: Follow, Actual: None`; wrong target). Full suites green: 782 UOContent, 869 Server. --- .../Tests/Mobiles/AI/PetOrderTests.cs | 53 +++++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 14 +++++ 2 files changed, 67 insertions(+) diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs index e5d7760ed..fbc6a2f81 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs @@ -67,6 +67,59 @@ public class PetOrderTests : IDisposable Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder); } + [Fact] + public void Stop_WhileAttacking_ResumedFollowTargetsTheMaster() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; // persistent = Follow + pet.ControlOrder = OrderType.Attack; // transient + Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder); + + pet.ControlOrder = OrderType.Stop; + + Assert.Equal(OrderType.Follow, pet.ControlOrder); + Assert.Equal(master, pet.ControlTarget); + } + + [Fact] + public void Stop_WhileAttacking_ResumedFollowKeepsAPetFriendAsItsTarget() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var friend = new PlayerMobile(World.NewMobile); + friend.DefaultMobileInit(); + friend.MoveToWorld(new Point3D(1002, 1000, 0), pet.Map); + _created.Add(friend); + + var victim = new PetTestStub(); + victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map); + _created.Add(victim); + + pet.ControlTarget = friend; // a pet friend said "all follow me" + pet.ControlOrder = OrderType.Follow; + pet.ControlTarget = victim; // then "all kill" — the attack order takes the target + pet.ControlOrder = OrderType.Attack; + + pet.ControlOrder = OrderType.Stop; + + Assert.Equal(OrderType.Follow, pet.ControlOrder); + Assert.Equal(friend, pet.ControlTarget); // still the friend, not the owner + } + + [Fact] + public void Stop_WhileAttacking_ResumedFollowSurvivesTheNextThink() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.ControlOrder = OrderType.Follow; + pet.ControlOrder = OrderType.Attack; + pet.ControlOrder = OrderType.Stop; // resumes Follow + + pet.AIObject.Obey(); + + Assert.Equal(OrderType.Follow, pet.ControlOrder); // not dropped to idle + Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder); + } + [Fact] public void Stop_WhileFollowing_CancelsToIdleNone() { diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index e09424328..3282493b8 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -28,16 +28,30 @@ public abstract partial class BaseAI // never re-derives the persistent command or re-anchors Home. See OnCurrentOrderChanged. private bool _resolvingOrder; + // Who a standing Follow follows: usually the master, but a pet friend can point it + // elsewhere ("all follow me"). Runtime-only, like PersistentOrder. + private Mobile _persistentFollowTarget; + // The controlled-pet wander anchor (Home) is a pure function of the persistent command. internal void SetPersistentOrder(OrderType order) { PersistentOrder = order; + _persistentFollowTarget = order == OrderType.Follow ? Mobile.ControlTarget ?? Mobile.ControlMaster : null; Mobile.Home = order is OrderType.Follow or OrderType.Guard ? Point3D.Zero : Mobile.Location; } // Resume the persistent command without re-deriving the persistent order or anchor. private void ResumePersistentOrder() { + // The ending order owns ControlTarget and leaves it cleared or pointing elsewhere; + // without a target DoOrderFollow cancels itself to idle on the next think. + if (PersistentOrder == OrderType.Follow) + { + Mobile.ControlTarget = _persistentFollowTarget?.Deleted == false + ? _persistentFollowTarget + : Mobile.ControlMaster; + } + _resolvingOrder = true; Mobile.ControlOrder = PersistentOrder; _resolvingOrder = false;