From 346228fa6979155342bbae3eb05d24e810231350 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:22:43 -0700 Subject: [PATCH] =?UTF-8?q?fix(ai):=20pet=20order/home=20refactor=20?= =?UTF-8?q?=E2=80=94=20stop=20&=20post-combat=20behavior=20(#2459)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes two related pet-behavior bugs and the underlying design flaw behind both: 1. **Post-combat erratic** — after a pet killed its `all kill` target it milled around erratically at the kill site (or failed to return to the master) until the player issued `all follow`/`all stop`. 2. **`all stop` returns home** — a pet with a non-zero `Home` walked back toward that location on `all stop` (on ML; on non-ML the old `DoOrderStop` was a no-op, so the sighting there came from a residual `Stay`). ### Root cause `ControlOrder` and the wild-creature `Home` field were overloaded to express several distinct ideas, mutated/read inconsistently across order transitions: - `Home` doubled as the controlled-pet "stay anchor" (`HandleStayOrder` set `Home = Location`) but nothing cleared it when the pet left the staying state; `HandleStopOrder` was the only handler that never touched it. - `DoOrderStop` had dropped RunUO's `Home = Location` re-anchor, so on ML it walked to a stale anchor. - The post-combat fallback was a fragile `_lastPetOrder` hack in `DoOrderNone` that re-anchored a resumed `Stay` at the corpse. - Controlled idle-wander bypassed the `CheckIdle()` rest gate that every non-controlled creature uses, so idling pets jittered every AI tick. ## Approach Separate three concepts that were tangled together: - **`ControlOrder`** — the active order (may be transient: Come/Attack/Drop). - **Persistent command** (`PersistentOrder` ∈ `{None, Stay, Follow, Guard}`) — the standing directive a pet falls back to when a transient order completes. Runtime-only (not serialized; reset to `None` on load) and **derived from master proximity on login** (near → Follow, far → Stay). - **Anchor** (`Home`) — a pure function of the persistent command, set only when that command changes (never on transient transitions or fallback-resume), so it can't go stale. ### Behavior - **Stop** is resolved immediately from what the pet was doing: Attack/Come → resume the persistent command; Follow/Guard → cancel to idle where it stands; Stay → stay put. - **Stay** holds its post (returns only if displaced, e.g. after a fight) — no shuffle. - **Idle** (`None`) is a gentle wander routed through `CheckMove/CanMoveNow/CheckIdle`, so idling pets take the same 15–25s rest periods as other creatures, on both ML and non-ML. - **Post-combat** the pet resumes its persistent command (a staying pet returns to its original post, not the corpse). - **Release** without a spawner anchors where the pet stands instead of pathing to a stale anchor. This restores the RunUO-intended behavior (verified against the RunUO reference) while fixing the ModernUO regressions. ## Tests New `PetOrderTests` (13 deterministic xUnit tests) cover: anchor lifecycle, the full Stop truth table, report 1 (post-combat return to post), report 2 (no stale-anchor walk-home), frozen-Stay/gated-idle wiring, release fix, derive-on-login, and a non-ML spot-check. The subjective wander *feel* is covered by a manual-QA checklist in the implementation plan. ## Notes - Engine project (`Projects/Server`) untouched; the one `BaseCreature.cs` change is the `ControlOrder` setter passing the previous order to `OnCurrentOrderChanged`. - `DoOrderCome` keeps auto-converting to `Stay` on arrival, which under the new model cleanly means "come and hold near me." - Commits in this PR are temporarily **unsigned** (the signing agent's passphrase cache expired mid-session); happy to re-sign / amend on request. --- .../Tests/Mobiles/AI/PetOrderTests.cs | 236 ++++++++++++++++++ .../Tests/Mobiles/AI/PetTestStub.cs | 51 ++++ .../UOContent/Mobiles/AI/BaseAI/PetLogin.cs | 50 ++++ .../Mobiles/AI/BaseAI/PetOrderHandlers.cs | 68 +++-- .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 111 ++++---- .../Mobiles/AI/BaseAI/WalkRandomLogic.cs | 11 + Projects/UOContent/Mobiles/BaseCreature.cs | 3 +- 7 files changed, 446 insertions(+), 84 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs create mode 100644 Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs new file mode 100644 index 000000000..e0fe22a39 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +[Collection("Sequential UOContent Tests")] +public class PetOrderTests : IDisposable +{ + // Track and delete every mobile we spawn so they don't linger in the shared static World + // and pollute other tests in this collection (e.g. Tracking's nearby-mobile scan). + private readonly List _created = new(); + + private (PlayerMobile master, PetTestStub pet) Spawn(Point3D masterLoc, Point3D petLoc) + { + var pair = PetTestSetup.SpawnControlledPet(masterLoc, petLoc); + _created.Add(pair.master); + _created.Add(pair.pet); + return pair; + } + + public void Dispose() + { + foreach (var m in _created) + { + m?.Delete(); + } + + _created.Clear(); + } + [Fact] + public void SetPersistentOrder_Stay_AnchorsHomeToCurrentLocation() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1005, 1000, 0)); + + pet.AIObject.SetPersistentOrder(OrderType.Stay); + + Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder); + Assert.Equal(pet.Location, pet.Home); + } + + [Fact] + public void SetPersistentOrder_Follow_ClearsAnchor() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1005, 1000, 0)); + pet.Home = new Point3D(900, 900, 0); // stale anchor + + pet.AIObject.SetPersistentOrder(OrderType.Follow); + + Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder); + Assert.Equal(Point3D.Zero, pet.Home); + } + + [Fact] + public void Stop_WhileAttacking_FallsBackToPersistentFollow() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0)); + 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); // resumed standing order + Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder); + } + + [Fact] + public void Stop_WhileFollowing_CancelsToIdleNone() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0)); + pet.ControlOrder = OrderType.Follow; + + pet.ControlOrder = OrderType.Stop; + + Assert.Equal(OrderType.None, pet.ControlOrder); + Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder); + Assert.Equal(pet.Location, pet.Home); // idle anchor = where stopped + } + + [Fact] + public void Stop_WhileStaying_RemainsStayingAtOriginalPost() + { + var post = new Point3D(1005, 1005, 0); + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), post); + pet.ControlOrder = OrderType.Stay; // Home = post + Assert.Equal(post, pet.Home); + + pet.ControlOrder = OrderType.Stop; + + Assert.Equal(OrderType.Stay, pet.ControlOrder); + Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder); + Assert.Equal(post, pet.Home); // post unchanged + } + + [Fact] + public void Stay_ThenFollow_ThenStop_DoesNotReturnToOldStayAnchor() // report 2 + { + var postA = new Point3D(1005, 1005, 0); + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), postA); + pet.ControlOrder = OrderType.Stay; // Home = A + pet.ControlOrder = OrderType.Follow; // Home cleared to Zero + pet.MoveToWorld(new Point3D(1050, 1050, 0), pet.Map); // walked to B + pet.ControlOrder = OrderType.Stop; // stop while following + + Assert.NotEqual(postA, pet.Home); // never re-acquires A + Assert.Equal(pet.Location, pet.Home); // idles at B + } + + [Fact] + public void AttackTargetLost_ResumesPersistentFollow() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0)); + pet.ControlOrder = OrderType.Follow; // persistent = Follow + pet.ControlOrder = OrderType.Attack; + pet.ControlTarget = null; // target gone + + pet.AIObject.DoOrderAttack(); // invalid-target path + + Assert.Equal(OrderType.Follow, pet.ControlOrder); + } + + [Fact] + public void StayPet_AttacksThenTargetLost_ReturnsToOriginalPost() // report 1 + { + var post = new Point3D(1005, 1005, 0); + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), post); + pet.ControlOrder = OrderType.Stay; // persistent = Stay, Home = post + pet.ControlOrder = OrderType.Attack; + pet.MoveToWorld(new Point3D(1060, 1060, 0), pet.Map); // chased far to the "corpse" + pet.ControlTarget = null; + + pet.AIObject.DoOrderAttack(); + + Assert.Equal(OrderType.Stay, pet.ControlOrder); + Assert.Equal(post, pet.Home); // anchor still the original post, not the corpse + } + + // NOTE: the test fixture does not load tile data, so Mobile.Move is blocked and Location + // never changes here. DoMoveImpl still sets Mobile.Direction before the (blocked) move, + // so an *attempted* wander is observable via Direction. The subjective wander cadence is + // covered by manual QA; these tests verify the gate/frozen wiring deterministically. + [Fact] + public void IdlePet_DoesNotAttemptToMove_WhileResting() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0)); + pet.ControlOrder = OrderType.Follow; + pet.ControlOrder = OrderType.Stop; // -> idle None + Assert.Equal(OrderType.None, pet.ControlOrder); + + pet.ForceIdle = true; // CheckIdle() reports resting -> idle wander must be skipped + pet.Direction = Direction.North; + for (var i = 0; i < 40; i++) + { + pet.AIObject.DoOrderNone(); + } + + Assert.Equal(Direction.North, pet.Direction); // gated by CheckIdle -> never attempts a step + } + + [Fact] + public void StayingPet_DoesNotAttemptToMove() + { + var post = new Point3D(1005, 1005, 0); + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), post); + pet.ControlOrder = OrderType.Stay; + pet.Direction = Direction.North; + + for (var i = 0; i < 40; i++) + { + pet.AIObject.DoOrderStay(); + } + + Assert.Equal(Direction.North, pet.Direction); // frozen -> no wander attempts + } + + [Fact] + public void Release_WithoutSpawner_AnchorsHomeToCurrentLocation() + { + var loc = new Point3D(1010, 1010, 0); + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), loc); + pet.ControlOrder = OrderType.Stay; // sets Home to loc + pet.Home = new Point3D(800, 800, 0); // simulate a stale anchor + pet.Spawner = null; + + pet.AIObject.DoOrderRelease(); + + Assert.Equal(loc, pet.Home); // released where it stands, not the stale point + } + + [Fact] + public void Login_NearMaster_DerivesFollow() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder); + + PetLoginHandler.DeriveFollowerOrders(master); + + Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder); + } + + [Fact] + public void Login_FarFromMaster_DerivesStay() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1040, 1000, 0)); + + PetLoginHandler.DeriveFollowerOrders(master); + + Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder); + } + + [Fact] + public void Stop_WhileFollowing_CancelsToIdle_NonML() + { + var previous = Core.Expansion; + try + { + Core.Expansion = Expansion.SE; // pre-ML: Core.ML is false + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0)); + pet.ControlOrder = OrderType.Follow; + + pet.ControlOrder = OrderType.Stop; + + // Stop/idle resolution is era-independent. + Assert.Equal(OrderType.None, pet.ControlOrder); + Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder); + Assert.Equal(pet.Location, pet.Home); + } + finally + { + Core.Expansion = previous; + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs new file mode 100644 index 000000000..a759cc024 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs @@ -0,0 +1,51 @@ +using Server; +using Server.Mobiles; + +namespace UOContent.Tests.Mobiles.AI; + +// Minimal tameable creature for AI-state tests. Uses the AIType constructor so the +// creature gets a real AIObject (the Serial ctor does not initialize AI). +public class PetTestStub : BaseCreature +{ + // When true, CheckIdle() reports "resting" so the idle-wander path must not move. + public bool ForceIdle { get; set; } + + public PetTestStub() : base(AIType.AI_Animal, FightMode.Closest, 10, 1) + { + Body = 0xC8; // dog + } + + // NPCSpeeds isn't configured in the test fixture; provide fixed speeds so the + // AIType constructor doesn't hit the unconfigured speed table. + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.2; + passiveSpeed = 0.4; + } + + public override bool CheckIdle() => ForceIdle || base.CheckIdle(); + + public PetTestStub(Serial serial) : base(serial) + { + } +} + +public static class PetTestSetup +{ + // Places a player master and a controlled pet on Felucca and returns both. + public static (PlayerMobile master, PetTestStub pet) SpawnControlledPet( + Point3D masterLoc, Point3D petLoc) + { + var map = Map.Felucca; + + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(masterLoc, map); + + var pet = new PetTestStub(); + pet.MoveToWorld(petLoc, map); + pet.SetControlMaster(master); // sets Controlled, Home=Zero, ControlOrder=Come + + return (master, pet); + } +} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs new file mode 100644 index 000000000..446b25b21 --- /dev/null +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs @@ -0,0 +1,50 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: PetLogin.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + ************************************************************************/ + +using ModernUO.CodeGeneratedEvents; + +namespace Server.Mobiles; + +public static class PetLoginHandler +{ + // Within this many tiles of the master we assume the pet was following; otherwise staying. + private const int FollowRange = 12; + + [OnEvent(nameof(PlayerMobile.PlayerLoginEvent))] + public static void OnLogin(PlayerMobile pm) => DeriveFollowerOrders(pm); + + // The persistent command is runtime-only and reset to None on load. When the master logs + // in we give each controlled pet that still has no standing command a sane one, inferred + // from proximity: near master -> Follow, otherwise Stay. + public static void DeriveFollowerOrders(PlayerMobile master) + { + if (master?.AllFollowers == null) + { + return; + } + + foreach (var follower in master.AllFollowers) + { + if (follower is BaseCreature { Controlled: true, Deleted: false } bc + && bc.ControlMaster == master + && bc.AIObject is { } ai + && ai.PersistentOrder == OrderType.None) + { + var near = bc.Map == master.Map && bc.GetDistanceToSqrt(master) <= FollowRange; + ai.SetPersistentOrder(near ? OrderType.Follow : OrderType.Stay); + } + } + } +} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs index 584fe486b..ecb456d2e 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs @@ -19,7 +19,7 @@ namespace Server.Mobiles; public abstract partial class BaseAI { - public virtual void OnCurrentOrderChanged() + public virtual void OnCurrentOrderChanged(OrderType previous) { if (Mobile.Deleted || Mobile.ControlMaster?.Deleted != false) { @@ -49,8 +49,9 @@ public abstract partial class BaseAI } case OrderType.Stop: { - HandleStopOrder(); - break; + // Stop is resolved into another order; it never rests as the active order. + ResolveStop(previous); + return; } case OrderType.Transfer: { @@ -83,6 +84,49 @@ public abstract partial class BaseAI break; } } + + // A freshly issued standing command becomes the persistent fallback and (re)anchors + // Home. Skipped while resuming a fallback so a resume never re-anchors. See + // ResumePersistentOrder. + if (!_resolvingOrder && Mobile.ControlOrder is OrderType.Stay or OrderType.Follow or OrderType.Guard) + { + SetPersistentOrder(Mobile.ControlOrder); + } + } + + // "Stop" cancels the active order, mapping to a resting order based on what the pet was + // doing: Attack/Come/etc. -> resume the persistent command; Follow/Guard -> cancel to idle + // (None) where it stands; Stay -> remain staying at its post. + private void ResolveStop(OrderType previous) + { + _commandIssuer?.RevealingAction(); + _commandIssuer = null; + Mobile.ControlTarget = null; + + switch (previous) + { + case OrderType.Stay: + { + _resolvingOrder = true; + Mobile.ControlOrder = OrderType.Stay; // remain staying; anchor untouched + _resolvingOrder = false; + break; + } + case OrderType.Follow: + case OrderType.Guard: + { + SetPersistentOrder(OrderType.None); // cancel standing order; anchor = current + _resolvingOrder = true; + Mobile.ControlOrder = OrderType.None; // idle + _resolvingOrder = false; + break; + } + default: // Attack / Come / Drop / None / etc. -> resume the standing order + { + ResumePersistentOrder(); + break; + } + } } private void HandleNoOrder() @@ -178,24 +222,8 @@ public abstract partial class BaseAI Mobile.Warmode = false; Mobile.Combatant = null; Mobile.PlaySound(Mobile.GetIdleSound()); - Mobile.Home = Mobile.Location; - _commandIssuer = null; - } - - private void HandleStopOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - _commandIssuer?.RevealingAction(); - Mobile.ControlTarget = null; - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; - Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; + // Home (the stay anchor) is owned by SetPersistentOrder, not this handler. } private void HandleReleaseOrder() diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 96844bf6f..1b7139a96 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -17,7 +17,29 @@ namespace Server.Mobiles; public abstract partial class BaseAI { - private OrderType _lastPetOrder = OrderType.None; + // The standing command a pet falls back to when a transient order (Attack/Come/Drop) + // completes: None, Stay, Follow, or Guard. Runtime-only (not serialized); reset to None + // on load and derived from master proximity on login. See PetLoginHandler. + internal OrderType PersistentOrder { get; private set; } = OrderType.None; + + // Guards anchor/persistent derivation while we resume a fallback order, so a resume + // never re-derives the persistent command or re-anchors Home. See OnCurrentOrderChanged. + private bool _resolvingOrder; + + // The controlled-pet wander anchor (Home) is a pure function of the persistent command. + internal void SetPersistentOrder(OrderType order) + { + PersistentOrder = order; + 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() + { + _resolvingOrder = true; + Mobile.ControlOrder = PersistentOrder; + _resolvingOrder = false; + } public virtual bool Obey() => !Mobile.Deleted && Mobile.ControlOrder switch @@ -43,33 +65,9 @@ public abstract partial class BaseAI Mobile.Warmode = IsValidCombatant(Mobile.Combatant); - if (_lastPetOrder == OrderType.Guard) - { - DebugSay("Target lost, resuming guard duty."); - - Mobile.ControlOrder = OrderType.Guard; - _lastPetOrder = OrderType.None; - return true; - } - else if (_lastPetOrder == OrderType.Stay) - { - DebugSay("Target lost, resuming stay position."); - - Mobile.ControlOrder = OrderType.Stay; - _lastPetOrder = OrderType.None; - return true; - } - else if (_lastPetOrder == OrderType.Follow) - { - DebugSay("Target lost, resuming follow command."); - - Mobile.ControlTarget = Mobile.ControlMaster; - Mobile.ControlOrder = OrderType.Follow; - _lastPetOrder = OrderType.None; - return true; - } - - WalkRandom(3, 2, 1); + // Pure idle: gently wander near the anchor, with CheckIdle rest periods. Pets resume + // a standing order via ResumePersistentOrder, not by re-deriving it here. + WalkRandomIdle(); return true; } @@ -106,8 +104,6 @@ public abstract partial class BaseAI if (Mobile.ControlTarget?.Deleted == false && Mobile.ControlTarget != Mobile) { - _lastPetOrder = OrderType.Follow; - FollowTarget(); } else @@ -147,9 +143,8 @@ public abstract partial class BaseAI this.DebugSayFormatted($"I am ordered to drop my items by {Mobile.ControlMaster?.Name ?? "Unknown"}."); - Mobile.ControlOrder = OrderType.None; - DropItems(); + ResumePersistentOrder(); return true; } @@ -224,7 +219,7 @@ public abstract partial class BaseAI { from.SendLocalizedMessage(1049691); // That person is already a friend. - Mobile.ControlOrder = OrderType.None; + ResumePersistentOrder(); return; } @@ -270,7 +265,7 @@ public abstract partial class BaseAI { from.SendLocalizedMessage(1070953); // That person is not a friend. - Mobile.ControlOrder = OrderType.None; + ResumePersistentOrder(); return; } @@ -296,8 +291,6 @@ public abstract partial class BaseAI return true; } - _lastPetOrder = OrderType.Guard; - FindCombatant(); if (IsValidCombatant(Mobile.Combatant)) @@ -364,7 +357,7 @@ public abstract partial class BaseAI DebugSay("Target is either dead, hidden, or out of range."); Mobile.ControlTarget = Mobile.ControlMaster; - Mobile.ControlOrder = OrderType.None; + ResumePersistentOrder(); if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor) { @@ -442,6 +435,9 @@ public abstract partial class BaseAI } else { + // No spawner to return to: anchor where it stands so it idle-wanders here + // instead of pathing toward a stale (e.g. former stay) anchor. + Mobile.Home = Mobile.Location; Action = ActionType.Wander; } @@ -473,31 +469,20 @@ public abstract partial class BaseAI this.DebugSayFormatted($"I have been ordered to stay by {Mobile.ControlMaster?.Name ?? "Unknown"}."); } - _lastPetOrder = OrderType.Stay; - - WalkRandomInHome(3, 2, 1); - return true; - } - - public virtual bool DoOrderStop() - { - if (CheckHerding()) + // Hold position at the post (Home). Stand still when there; only walk back if displaced + // (e.g. after chasing a kill). No idle shuffle. + if (Mobile.Home != Point3D.Zero && Mobile.Location != Mobile.Home) { - this.DebugSayFormatted($"I am being herded by {Mobile.ControlTarget?.Name ?? "Unknown"}."); - } - else - { - this.DebugSayFormatted($"I have been ordered to stop by {Mobile.ControlMaster?.Name ?? "Unknown"}."); - } - - if (Core.ML) - { - WalkRandomInHome(5, 2, 1); + DoMove(Mobile.GetDirectionTo(Mobile.Home)); } return true; } + // Stop is resolved into another order in OnCurrentOrderChanged and never rests as the + // active order; this is a defensive no-op. + public virtual bool DoOrderStop() => true; + public virtual bool DoOrderTransfer() { if (Mobile.IsDeadPet) @@ -519,7 +504,7 @@ public abstract partial class BaseAI { from.SendLocalizedMessage(502040); // As a young player, you may not friend pets to older players. - Mobile.ControlOrder = OrderType.None; + ResumePersistentOrder(); return true; } @@ -527,7 +512,7 @@ public abstract partial class BaseAI { from.SendLocalizedMessage(502041); // As an older player, you may not friend pets to young players. - Mobile.ControlOrder = OrderType.None; + ResumePersistentOrder(); return true; } @@ -536,7 +521,7 @@ public abstract partial class BaseAI SendTransferRefusalMessages(from, to, 1043248, 1043249); // 1043248: The pet refuses to be transferred because it will not obey ~1_NAME~.~3_BLANK~ // 1043249: The pet will not accept you as a master because it does not trust you.~3_BLANK~ - Mobile.ControlOrder = OrderType.None; + ResumePersistentOrder(); return true; } @@ -545,7 +530,7 @@ public abstract partial class BaseAI SendTransferRefusalMessages(from, to, 1043250, 1043251); // 1043250: The pet refuses to be transferred because it will not obey you sufficiently.~3_BLANK~ // 1043251: The pet will not accept you as a master because it does not trust ~2_NAME~.~3_BLANK~ - Mobile.ControlOrder = OrderType.None; + ResumePersistentOrder(); return true; } @@ -554,7 +539,7 @@ public abstract partial class BaseAI { from.SendMessage("You can not transfer a pet while in combat."); to.SendMessage("You can not transfer a pet while in combat."); - Mobile.ControlOrder = OrderType.None; + ResumePersistentOrder(); return true; } @@ -563,7 +548,7 @@ public abstract partial class BaseAI if (fromState == null || toState == null) { - Mobile.ControlOrder = OrderType.None; + ResumePersistentOrder(); return true; } @@ -573,7 +558,7 @@ public abstract partial class BaseAI // You cannot transfer a pet with a trade pending to.SendLocalizedMessage(1010507); // You cannot transfer a pet with a trade pending - Mobile.ControlOrder = OrderType.None; + ResumePersistentOrder(); return true; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/WalkRandomLogic.cs b/Projects/UOContent/Mobiles/AI/BaseAI/WalkRandomLogic.cs index 6d0058f6d..9ac53b96d 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/WalkRandomLogic.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/WalkRandomLogic.cs @@ -38,6 +38,17 @@ public abstract partial class BaseAI } } + // Idle wander for controlled pets. Routes through the same CheckMove/CanMoveNow/CheckIdle + // gate that non-controlled DoActionWander uses, so idling pets take the gentle 15-25s + // CheckIdle rest periods instead of shuffling every AI tick. + public void WalkRandomIdle() + { + if (CheckMove() && CanMoveNow(out _) && !Mobile.CheckIdle()) + { + WalkRandomInHome(3, 2, 1); + } + } + public virtual void WalkRandomInHome(int chanceToNotMove, int chanceToDir, int steps) { if (Mobile.Deleted || Mobile.DisallowAllMoves) diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index e8bdbc394..75f516afc 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -803,9 +803,10 @@ namespace Server.Mobiles get => m_ControlOrder; set { + var previous = m_ControlOrder; m_ControlOrder = value; - AIObject?.OnCurrentOrderChanged(); + AIObject?.OnCurrentOrderChanged(previous); InvalidateProperties();