From 36e09bae1353ef61da734d98599d00ad4ade0c9d Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:10:30 -0700 Subject: [PATCH] fix: Pets stop permanently, simplifies pet movement speed (#2401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fixes pets falling behind mounted masters in AOS+ by setting `CurrentSpeed = 0.1` when following master - Fixes AI timer permanently stopping when `Obey()`/`Think()` returns `false` for transient conditions - Fixes controlled pets losing AI in inactive sectors (pet follows owner across sector boundary, sector deactivates, AI dies) - Adds defense-in-depth: AI timer restarts on pet resurrection and order changes ## AI Timer Permanent Stop (Bug Fix) `AITimer.OnTick()` called `Stop()` when `Obey()` or `Think()` returned `false`. By that point, `ShouldStop()` had already validated the creature is alive, on a valid map, and in an active sector — so any `false` return was a **transient** condition, not terminal. The timer stopped permanently with no mechanism to restart it. **Scenarios that triggered permanent AI death:** - Dead bonded pet with attack order (`DoOrderAttack` returned `false` for `IsDeadPet`) - Failed pet transfer — loyalty refusal, combat, disconnected player, or pending trade (`DoOrderTransfer` returned `false` for 5 different transient conditions) - Unknown `OrderType` or `ActionType` (defensive defaults) **Fixes:** - Removed `Stop()` from the `Obey()`/`Think()` failure path — timer skips the tick and fires again next interval - Changed `DoOrderAttack()` and all five `DoOrderTransfer()` failure paths to return `true` (correct semantics: these are recoverable states, not "stop AI forever" signals) - Added `Activate()` call in `ResurrectPet()` — ensures dead bonded pets have AI running after resurrection - Added `Activate()` call in `OnCurrentOrderChanged()` — self-heals timer if any voice command is issued to a pet with a stopped timer ## Controlled Pet Sector Deactivation (Bug Fix) `ShouldStop()` stopped the AI timer for **all** `PlayerRangeSensitive` creatures in inactive sectors, including controlled pets. But `Deactivate()` intentionally exempted controlled pets. The exemption was dead code — `ShouldStop()` bypassed it. This matters when a pet follows its owner across a sector boundary: the owner enters the next sector (active), the pet's old sector deactivates (no more players), and the pet's AI dies. The pet stops following and stands there until the player backtracks far enough to reactivate the sector. **Fix:** Added `Controlled` check to `ShouldStop()` to match `Deactivate()`. Controlled pets now keep their AI running in inactive sectors. The overhead is negligible — controlled pets are bounded by follower slots. ## Movement Speed Simplification - Simplifies `AITimer` to use `CurrentSpeed` directly as the tick interval (in seconds), removing the complex multiplier/floor logic in `GetBaseInterval` - Refactors `DoMoveImpl` speed assignment into explicit if/else for clarity - AOS+ pets following master use `CurrentSpeed = 0.1` (100ms), matching `RunMountDelay` ## Files Changed - `AITimer.cs` — removed `Stop()` on Obey/Think failure, added `Controlled` exemption to `ShouldStop()`, simplified interval logic - `BaseAI.cs` — renamed `_timer` to `AITimer` (public), simplified `Deactivate()`, fixed `ReturnToHome` to use `Activate()` - `PetOrders.cs` — `DoOrderAttack` and `DoOrderTransfer` return `true` for transient failures - `PetOrderHandlers.cs` — `OnCurrentOrderChanged()` calls `Activate()` to self-heal stopped timers - `BaseCreature.cs` — `ResurrectPet()` calls `Activate()`, fixed `GoHome_Callback` PlayerRangeSensitive check - `AIMovement.cs` — refactored speed assignment, AOS+ follow-master speed fix --- .../UOContent/Mobiles/AI/BaseAI/AIMovement.cs | 19 ++++++-- .../UOContent/Mobiles/AI/BaseAI/AITimer.cs | 38 +++++---------- .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 46 +++++++++---------- .../Mobiles/AI/BaseAI/PetOrderHandlers.cs | 2 + .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 12 ++--- Projects/UOContent/Mobiles/BaseCreature.cs | 8 ++-- 6 files changed, 61 insertions(+), 64 deletions(-) diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 376fa6a0d..12fa56352 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -71,9 +71,22 @@ public abstract partial class BaseAI if (TryMove(d)) { - Mobile.CurrentSpeed = Mobile.Hits < Mobile.HitsMax * 0.3 - ? BadlyHurtMoveDelay(Mobile) - : Mobile.Warmode || Mobile.Combatant != null ? Mobile.ActiveSpeed : Mobile.PassiveSpeed; + if (Core.AOS && IsFollowingMaster()) + { + Mobile.CurrentSpeed = 0.1; + } + else if (Mobile.Hits < Mobile.HitsMax * 0.3) + { + Mobile.CurrentSpeed = BadlyHurtMoveDelay(Mobile); + } + else if (Mobile.Warmode || Mobile.Combatant != null) + { + Mobile.CurrentSpeed = Mobile.ActiveSpeed; + } + else + { + Mobile.CurrentSpeed = Mobile.PassiveSpeed; + } return MoveResult.Success; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index 5a6915d0a..3c08d944b 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -17,37 +17,23 @@ using System; namespace Server.Mobiles; -internal sealed class AITimer : Timer +public sealed class AITimer : Timer { private readonly BaseAI _owner; private int _detectHiddenMinDelay; private int _detectHiddenMaxDelay; public AITimer(BaseAI owner) : base(TimeSpan.FromMilliseconds(Utility.Random(3000)), - TimeSpan.FromMilliseconds(GetBaseInterval(owner))) + TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed)) { _owner = owner; _owner._nextDetectHidden = Core.TickCount; } - private static double GetBaseInterval(BaseAI owner) + public void Activate() { - double interval; - - if (owner.IsFollowingMaster()) - { - interval = owner.Mobile.CurrentSpeed * (Core.AOS ? 100 : 400); - } - else if (owner.Mobile.CurrentSpeed <= 0.4) - { - interval = owner.Mobile.CurrentSpeed * 1000; - } - else - { - interval = owner.Mobile.CurrentSpeed * 3000; - } - - return Math.Max(interval, Core.AOS ? 100 : 200); + Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); + Start(); } protected override void OnTick() @@ -58,8 +44,6 @@ internal sealed class AITimer : Timer return; } - Interval = TimeSpan.FromMilliseconds(GetBaseInterval(_owner)); - _owner.Mobile.OnThink(); if (ShouldStop()) @@ -68,11 +52,11 @@ internal sealed class AITimer : Timer return; } + Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); HandleBardEffects(); if (_owner.Mobile.Controlled ? !_owner.Obey() : !_owner.Think()) { - Stop(); return; } @@ -86,14 +70,14 @@ internal sealed class AITimer : Timer return true; } - if (_owner.Mobile.Map != null && _owner.Mobile.Map != Map.Internal && - (!_owner.Mobile.PlayerRangeSensitive || _owner.Mobile.Map.GetSector(_owner.Mobile.Location).Active)) + if (_owner.Mobile.Map == null || _owner.Mobile.Map == Map.Internal || _owner.Mobile.PlayerRangeSensitive && + !_owner.Mobile.Controlled && !_owner.Mobile.Map.GetSector(_owner.Mobile.Location).Active) { - return false; + _owner.Deactivate(); + return true; } - _owner.Deactivate(); - return true; + return false; } private void HandleBardEffects() diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index d849354d9..57b6f6a25 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -28,10 +28,11 @@ public abstract partial class BaseAI { private ActionType _action; public long _nextDetectHidden; - public PathFollower Path { get; protected set; } - public readonly Timer _timer; public DateTime _lastOrder = DateTime.MinValue; public Mobile _commandIssuer; + + public PathFollower Path { get; protected set; } + public AITimer AITimer { get; } public long NextMove { get; set; } public BaseCreature Mobile { get; } @@ -43,11 +44,11 @@ public abstract partial class BaseAI public BaseAI(BaseCreature m) { Mobile = m; - _timer = new AITimer(this); + AITimer = new AITimer(this); if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active) { - _timer.Start(); + AITimer.Start(); } if (Action != ActionType.Wander) @@ -360,7 +361,7 @@ public abstract partial class BaseAI else if (Mobile.IsAnimatedDead) { FollowMaster(); - + if (CheckMove() && CanMoveNow(out _) && !Mobile.CheckIdle()) { WalkRandomInHome(3, 2, 1); @@ -437,7 +438,7 @@ public abstract partial class BaseAI return true; } - public bool IsValidCombatant(Mobile combatant) => IsValidFocusMob(combatant) + public bool IsValidCombatant(Mobile combatant) => IsValidFocusMob(combatant) && (Mobile.InLOS(combatant) || IsHostile(combatant)); public bool IsValidFocusMob(Mobile focusMob) => @@ -594,7 +595,7 @@ public abstract partial class BaseAI Action = ActionType.Wander; return false; } - + Action = ActionType.Flee; return true; } @@ -618,14 +619,14 @@ public abstract partial class BaseAI { return false; } - + var hitPercent = (double)Mobile.Hits / Mobile.HitsMax; - + if (hitPercent > FleeHealthThreshold) { return false; } - + return Utility.RandomDouble() < FleeChance; } @@ -637,12 +638,12 @@ public abstract partial class BaseAI { return false; } - + if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true)) { return Utility.RandomDouble() < BackoffChance; } - + return false; } @@ -660,7 +661,7 @@ public abstract partial class BaseAI else { DebugSay("Focus target missing. Wandering..."); - + Action = ActionType.Wander; } @@ -882,8 +883,8 @@ public abstract partial class BaseAI return !valid && (acqType != FightMode.Evil || (bc?.GetMaster()?.Karma ?? m.Karma) >= 0); } - private bool IsHostile(Mobile from) => Mobile.Combatant == from || from.Combatant == Mobile - || IsAggressor(from) || IsAggressed(from); + private bool IsHostile(Mobile from) => + Mobile.Combatant == from || from.Combatant == Mobile || IsAggressor(from) || IsAggressed(from); private bool IsAggressor(Mobile from) { @@ -956,14 +957,9 @@ public abstract partial class BaseAI public virtual void Deactivate() { - if (!Mobile.PlayerRangeSensitive) - { - return; - } - if (Mobile.Map == Map.Internal || !Mobile.Controlled && !Mobile.Map.GetSector(Mobile.Location).Active) { - _timer.Stop(); + AITimer.Stop(); } if (ShouldReturnToHome(Mobile.Spawner)) @@ -990,19 +986,19 @@ public abstract partial class BaseAI Mobile.MoveToWorld(loc, spawner.Map); } - _timer.Start(); + Activate(); } public virtual void Activate() { - if (!_timer.Running) + if (!AITimer.Running) { - _timer.Start(); + AITimer.Activate(); } } public virtual void OnCurrentSpeedChanged() { - _timer.Interval = TimeSpan.FromMilliseconds(Mobile.CurrentSpeed * 1000); + AITimer.Interval = TimeSpan.FromSeconds(Mobile.CurrentSpeed); } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs index 214506b90..584fe486b 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs @@ -26,6 +26,8 @@ public abstract partial class BaseAI return; } + Activate(); + switch (Mobile.ControlOrder) { case OrderType.None: diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 61787dbf6..96844bf6f 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -337,7 +337,7 @@ public abstract partial class BaseAI { if (Mobile.IsDeadPet) { - return false; + return true; } if (IsInvalidControlTarget(Mobile.ControlTarget)) @@ -537,7 +537,7 @@ public abstract partial class BaseAI // 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; - return false; + return true; } if (!Mobile.CanBeControlledBy(from)) @@ -546,7 +546,7 @@ public abstract partial class BaseAI // 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; - return false; + return true; } if (Mobile.Combatant != null || Mobile.Aggressors.Count > 0 || @@ -555,7 +555,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; - return false; + return true; } var fromState = from.NetState; @@ -564,7 +564,7 @@ public abstract partial class BaseAI if (fromState == null || toState == null) { Mobile.ControlOrder = OrderType.None; - return false; + return true; } if (from.HasTrade || to.HasTrade) @@ -574,7 +574,7 @@ public abstract partial class BaseAI to.SendLocalizedMessage(1010507); // You cannot transfer a pet with a trade pending Mobile.ControlOrder = OrderType.None; - return false; + return true; } var container = fromState.AddTrade(toState); diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index dd6aafebb..be1733290 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -2245,7 +2245,7 @@ namespace Server.Mobiles public void ChangeAIType(AIType newAI) { - AIObject?._timer.Stop(); + AIObject?.AITimer.Stop(); if (ForcedAI != null) { @@ -2368,7 +2368,7 @@ namespace Server.Mobiles { if (AIObject != null) { - AIObject._timer?.Stop(); + AIObject.AITimer?.Stop(); AIObject = null; } @@ -3863,6 +3863,8 @@ namespace Server.Mobiles OnAfterResurrect(); + AIObject?.Activate(); + var owner = ControlMaster; if (owner?.Deleted == false && owner.Map == Map && owner.InRange(this, 12) && CanSee(owner) && InLOS(owner)) @@ -3914,7 +3916,7 @@ namespace Server.Mobiles { SetLocation(Home, true); - if (!Map.GetSector(X, Y).Active) + if (PlayerRangeSensitive && !Map.GetSector(X, Y).Active) { AIObject?.Deactivate(); }