fix: Pets stop permanently, simplifies pet movement speed (#2401)

## 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
This commit is contained in:
Kamron Batman 2026-04-25 11:10:30 -07:00 committed by GitHub
parent 6a132560eb
commit 36e09bae13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 61 additions and 64 deletions

View file

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

View file

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

View file

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

View file

@ -26,6 +26,8 @@ public abstract partial class BaseAI
return;
}
Activate();
switch (Mobile.ControlOrder)
{
case OrderType.None:

View file

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

View file

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