## 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
595 lines
18 KiB
C#
595 lines
18 KiB
C#
/*************************************************************************
|
|
* ModernUO *
|
|
* Copyright 2019-2026 - ModernUO Development Team *
|
|
* Email: hi@modernuo.com *
|
|
* File: PetOrders.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 <http://www.gnu.org/licenses/>. *
|
|
************************************************************************/
|
|
|
|
namespace Server.Mobiles;
|
|
|
|
public abstract partial class BaseAI
|
|
{
|
|
private OrderType _lastPetOrder = OrderType.None;
|
|
|
|
public virtual bool Obey() =>
|
|
!Mobile.Deleted && Mobile.ControlOrder switch
|
|
{
|
|
OrderType.None => DoOrderNone(),
|
|
OrderType.Come => DoOrderCome(),
|
|
OrderType.Drop => DoOrderDrop(),
|
|
OrderType.Friend => DoOrderFriend(),
|
|
OrderType.Unfriend => DoOrderUnfriend(),
|
|
OrderType.Guard => DoOrderGuard(),
|
|
OrderType.Attack => DoOrderAttack(),
|
|
OrderType.Release => DoOrderRelease(),
|
|
OrderType.Stay => DoOrderStay(),
|
|
OrderType.Stop => DoOrderStop(),
|
|
OrderType.Follow => DoOrderFollow(),
|
|
OrderType.Transfer => DoOrderTransfer(),
|
|
_ => false
|
|
};
|
|
|
|
public virtual bool DoOrderNone()
|
|
{
|
|
DebugSay("I currently have no orders.");
|
|
|
|
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);
|
|
return true;
|
|
}
|
|
|
|
public virtual bool DoOrderCome()
|
|
{
|
|
if (CheckHerding())
|
|
{
|
|
this.DebugSayFormatted($"I am being herded by {Mobile.ControlTarget?.Name ?? "Unknown"}.");
|
|
return true;
|
|
}
|
|
|
|
if (Mobile.ControlMaster?.Deleted != false)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
WalkMobileRange(Mobile.ControlMaster, 1, false, 1, 2);
|
|
|
|
if (Mobile.GetDistanceToSqrt(Mobile.ControlMaster) <= 2)
|
|
{
|
|
Mobile.ControlOrder = OrderType.Stay;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public virtual bool DoOrderFollow()
|
|
{
|
|
if (CheckHerding())
|
|
{
|
|
this.DebugSayFormatted($"I am being herded by {Mobile.ControlTarget?.Name ?? "Unknown"}.");
|
|
return true;
|
|
}
|
|
|
|
if (Mobile.ControlTarget?.Deleted == false && Mobile.ControlTarget != Mobile)
|
|
{
|
|
_lastPetOrder = OrderType.Follow;
|
|
|
|
FollowTarget();
|
|
}
|
|
else
|
|
{
|
|
DebugSay("I have no one to follow.");
|
|
|
|
Mobile.ControlOrder = OrderType.None;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private void FollowTarget()
|
|
{
|
|
var currentDistance = (int)Mobile.GetDistanceToSqrt(Mobile.ControlTarget);
|
|
|
|
if (currentDistance > Mobile.RangePerception)
|
|
{
|
|
this.DebugSayFormatted($"Master {Mobile.ControlMaster?.Name ?? "Unknown"} is missing. Staying put.");
|
|
return;
|
|
}
|
|
|
|
this.DebugSayFormatted($"I am ordered to follow {Mobile.ControlTarget?.Name}.");
|
|
|
|
if (currentDistance > 1)
|
|
{
|
|
WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2);
|
|
}
|
|
}
|
|
|
|
public virtual bool DoOrderDrop()
|
|
{
|
|
if (Mobile.IsDeadPet || !Mobile.CanDrop)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
this.DebugSayFormatted($"I am ordered to drop my items by {Mobile.ControlMaster?.Name ?? "Unknown"}.");
|
|
|
|
Mobile.ControlOrder = OrderType.None;
|
|
|
|
DropItems();
|
|
return true;
|
|
}
|
|
|
|
private void DropItems()
|
|
{
|
|
var pack = Mobile.Backpack;
|
|
|
|
if (pack == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var items = pack.Items;
|
|
|
|
for (var i = items.Count - 1; i >= 0; --i)
|
|
{
|
|
if (i < items.Count)
|
|
{
|
|
items[i].MoveToWorld(Mobile.Location, Mobile.Map);
|
|
}
|
|
}
|
|
}
|
|
|
|
public virtual bool DoOrderFriend()
|
|
{
|
|
var from = Mobile.ControlMaster;
|
|
var to = Mobile.ControlTarget;
|
|
|
|
HandleFriendRequest(from, to);
|
|
return true;
|
|
}
|
|
|
|
private void HandleFriendRequest(Mobile from, Mobile to)
|
|
{
|
|
var youngFrom = from is PlayerMobile mobile && mobile.Young;
|
|
var youngTo = to is PlayerMobile playerMobile && playerMobile.Young;
|
|
|
|
if (youngFrom && !youngTo)
|
|
{
|
|
from.SendLocalizedMessage(502040);
|
|
// As a young player, you may not friend pets to older players.
|
|
return;
|
|
}
|
|
|
|
if (!youngFrom && youngTo)
|
|
{
|
|
from.SendLocalizedMessage(502041);
|
|
// As an older player, you may not friend pets to young players.
|
|
return;
|
|
}
|
|
|
|
if (!from.CanBeBeneficial(to, true))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (to?.Deleted != false || from == to || !to.Player)
|
|
{
|
|
Mobile.PublicOverheadMessage(MessageType.Regular, 0x3B2, 502039);
|
|
// *looks confused*
|
|
return;
|
|
}
|
|
|
|
if (from.HasTrade || to.HasTrade)
|
|
{
|
|
(from.HasTrade ? from : to).SendLocalizedMessage(1070947);
|
|
// You cannot friend a pet with a trade pending
|
|
return;
|
|
}
|
|
|
|
if (Mobile.IsPetFriend(to))
|
|
{
|
|
from.SendLocalizedMessage(1049691);
|
|
// That person is already a friend.
|
|
Mobile.ControlOrder = OrderType.None;
|
|
return;
|
|
}
|
|
|
|
if (!Mobile.AllowNewPetFriend)
|
|
{
|
|
from.SendLocalizedMessage(1005482);
|
|
// Your pet does not seem to be interested in making new friends right now.
|
|
return;
|
|
}
|
|
|
|
from.SendLocalizedMessage(1049676, $"{Mobile.Name}\t{to.Name}");
|
|
// ~1_NAME~ will now accept movement commands from ~2_NAME~.
|
|
|
|
to.SendLocalizedMessage(1043246, $"{from.Name}\t{Mobile.Name}");
|
|
// ~1_NAME~ has granted you the ability to give orders to their pet ~2_PET_NAME~.
|
|
// This creature will now consider you as a friend.
|
|
|
|
Mobile.AddPetFriend(to);
|
|
|
|
Mobile.ControlTarget = to;
|
|
Mobile.ControlOrder = OrderType.Follow;
|
|
}
|
|
|
|
public virtual bool DoOrderUnfriend()
|
|
{
|
|
var from = Mobile.ControlMaster;
|
|
var to = Mobile.ControlTarget;
|
|
|
|
HandleUnfriendRequest(from, to);
|
|
return true;
|
|
}
|
|
|
|
private void HandleUnfriendRequest(Mobile from, Mobile to)
|
|
{
|
|
if (from?.Deleted != false || to?.Deleted != false || from == to || !to.Player)
|
|
{
|
|
Mobile.PublicOverheadMessage(MessageType.Regular, 0x3B2, 502039);
|
|
// *looks confused*
|
|
return;
|
|
}
|
|
|
|
if (!Mobile.IsPetFriend(to))
|
|
{
|
|
from.SendLocalizedMessage(1070953);
|
|
// That person is not a friend.
|
|
Mobile.ControlOrder = OrderType.None;
|
|
return;
|
|
}
|
|
|
|
from.SendLocalizedMessage(1070951, $"{Mobile.Name}\t{to.Name}");
|
|
// ~1_NAME~ will no longer accept movement commands from ~2_NAME~.
|
|
|
|
to.SendLocalizedMessage(1070952, $"{from.Name}\t{Mobile.Name}");
|
|
// ~1_NAME~ has no longer granted you the ability to give orders to their pet ~2_PET_NAME~.
|
|
// This creature will no longer consider you as a friend.
|
|
|
|
Mobile.RemovePetFriend(to);
|
|
|
|
Mobile.ControlTarget = from;
|
|
Mobile.ControlOrder = OrderType.Follow;
|
|
}
|
|
|
|
public virtual bool DoOrderGuard()
|
|
{
|
|
var controlMaster = Mobile.ControlMaster;
|
|
|
|
if (Mobile.IsDeadPet || controlMaster?.Deleted != false)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
_lastPetOrder = OrderType.Guard;
|
|
|
|
FindCombatant();
|
|
|
|
if (IsValidCombatant(Mobile.Combatant))
|
|
{
|
|
var combatant = Mobile.Combatant;
|
|
|
|
this.DebugSayFormatted($"Attacking target: {combatant.Name}");
|
|
|
|
Mobile.Combatant = combatant;
|
|
Mobile.FocusMob = combatant;
|
|
Action = ActionType.Combat;
|
|
|
|
Think();
|
|
}
|
|
else
|
|
{
|
|
this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}.");
|
|
|
|
var guardLocation = controlMaster.Location;
|
|
|
|
var distance = (int)Mobile.GetDistanceToSqrt(guardLocation);
|
|
|
|
if (distance > 3)
|
|
{
|
|
DoMove(Mobile.GetDirectionTo(guardLocation));
|
|
}
|
|
else
|
|
{
|
|
WalkRandom(3, 1, 1);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public virtual bool DoOrderAttack()
|
|
{
|
|
if (Mobile.IsDeadPet)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (IsInvalidControlTarget(Mobile.ControlTarget))
|
|
{
|
|
HandleInvalidControlTarget();
|
|
}
|
|
else
|
|
{
|
|
Mobile.Combatant = Mobile.ControlTarget;
|
|
|
|
this.DebugSayFormatted($"Attacking target: {Mobile.ControlTarget?.Name}");
|
|
|
|
Think();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private bool IsInvalidControlTarget(Mobile target) => target?.Deleted != false || target.Map != Mobile.Map
|
|
|| !target.Alive || target.IsDeadBondedPet || target.Hidden;
|
|
|
|
private void HandleInvalidControlTarget()
|
|
{
|
|
DebugSay("Target is either dead, hidden, or out of range.");
|
|
|
|
Mobile.ControlTarget = Mobile.ControlMaster;
|
|
Mobile.ControlOrder = OrderType.None;
|
|
|
|
if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor)
|
|
{
|
|
FindCombatant();
|
|
}
|
|
}
|
|
|
|
private void FindCombatant()
|
|
{
|
|
var controlMaster = Mobile.ControlMaster;
|
|
|
|
foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception))
|
|
{
|
|
if (!Mobile.CanSee(aggr) || aggr.IsDeadBondedPet || !aggr.Alive)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var isAttackingPet = aggr.Combatant == Mobile;
|
|
var isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster;
|
|
|
|
if (isAttackingPet || isAttackingMaster)
|
|
{
|
|
if (Mobile.InLOS(aggr))
|
|
{
|
|
Mobile.ControlTarget = aggr;
|
|
Mobile.ControlOrder = OrderType.Attack;
|
|
Mobile.Combatant = aggr;
|
|
|
|
var target = isAttackingMaster ? "master" : "me";
|
|
this.DebugSayFormatted($"{aggr.Name} is attacking my {target}! Engaging...");
|
|
|
|
Think();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (controlMaster?.Aggressors != null)
|
|
{
|
|
for (var i = 0; i < controlMaster.Aggressors.Count; i++)
|
|
{
|
|
var aggressor = controlMaster.Aggressors[i].Attacker;
|
|
|
|
if (aggressor?.Deleted != false || !aggressor.Alive || aggressor.IsDeadBondedPet)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (Mobile.InRange(aggressor, Mobile.RangePerception) && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor))
|
|
{
|
|
Mobile.ControlTarget = aggressor;
|
|
Mobile.ControlOrder = OrderType.Attack;
|
|
Mobile.Combatant = aggressor;
|
|
|
|
this.DebugSayFormatted($"{aggressor.Name} recently attacked my master! Retaliating...");
|
|
|
|
Think();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public virtual bool DoOrderRelease()
|
|
{
|
|
DebugSay("I have been released to the wild.");
|
|
|
|
var spawner = Mobile.Spawner;
|
|
|
|
if (spawner != null)
|
|
{
|
|
Mobile.Home = spawner.GetSpawnPosition(Mobile, spawner.Map);
|
|
Mobile.RangeHome = spawner.WalkingRange;
|
|
}
|
|
else
|
|
{
|
|
Action = ActionType.Wander;
|
|
}
|
|
|
|
if (Mobile.DeleteOnRelease || Mobile.IsDeadPet)
|
|
{
|
|
Mobile.Delete();
|
|
}
|
|
else
|
|
{
|
|
Mobile.BeginDeleteTimer();
|
|
|
|
if (Mobile.CanDrop)
|
|
{
|
|
Mobile.DropBackpack();
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public virtual bool DoOrderStay()
|
|
{
|
|
if (CheckHerding())
|
|
{
|
|
this.DebugSayFormatted($"I am being herded by {Mobile.ControlTarget?.Name ?? "Unknown"}.");
|
|
}
|
|
else
|
|
{
|
|
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())
|
|
{
|
|
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);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public virtual bool DoOrderTransfer()
|
|
{
|
|
if (Mobile.IsDeadPet)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var from = Mobile.ControlMaster;
|
|
var to = Mobile.ControlTarget;
|
|
|
|
if (from?.Deleted == false && to?.Deleted == false && from != to && to.Player)
|
|
{
|
|
this.DebugSayFormatted($"Beginning transfer with {to.Name}");
|
|
|
|
var youngFrom = from is PlayerMobile mobile && mobile.Young;
|
|
var youngTo = to is PlayerMobile playerMobile && playerMobile.Young;
|
|
|
|
if (youngFrom && !youngTo)
|
|
{
|
|
from.SendLocalizedMessage(502040);
|
|
// As a young player, you may not friend pets to older players.
|
|
Mobile.ControlOrder = OrderType.None;
|
|
return true;
|
|
}
|
|
|
|
if (!youngFrom && youngTo)
|
|
{
|
|
from.SendLocalizedMessage(502041);
|
|
// As an older player, you may not friend pets to young players.
|
|
Mobile.ControlOrder = OrderType.None;
|
|
return true;
|
|
}
|
|
|
|
if (!Mobile.CanBeControlledBy(to))
|
|
{
|
|
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;
|
|
return true;
|
|
}
|
|
|
|
if (!Mobile.CanBeControlledBy(from))
|
|
{
|
|
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;
|
|
return true;
|
|
}
|
|
|
|
if (Mobile.Combatant != null || Mobile.Aggressors.Count > 0 ||
|
|
Mobile.Aggressed.Count > 0 || Core.TickCount < Mobile.NextCombatTime)
|
|
{
|
|
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 true;
|
|
}
|
|
|
|
var fromState = from.NetState;
|
|
var toState = to.NetState;
|
|
|
|
if (fromState == null || toState == null)
|
|
{
|
|
Mobile.ControlOrder = OrderType.None;
|
|
return true;
|
|
}
|
|
|
|
if (from.HasTrade || to.HasTrade)
|
|
{
|
|
from.SendLocalizedMessage(1010507);
|
|
// 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;
|
|
return true;
|
|
}
|
|
|
|
var container = fromState.AddTrade(toState);
|
|
container.DropItem(new TransferItem(Mobile));
|
|
}
|
|
|
|
Mobile.ControlOrder = OrderType.Stay;
|
|
return true;
|
|
}
|
|
|
|
private static void SendTransferRefusalMessages(Mobile from, Mobile to, int fromMessage, int toMessage)
|
|
{
|
|
var args = $"{to.Name}\t{from.Name}\t ";
|
|
|
|
from.SendLocalizedMessage(fromMessage, args);
|
|
to.SendLocalizedMessage(toMessage, args);
|
|
}
|
|
}
|