ModernUO/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs
Kamron Batman f606225f47
fix: pets freeze, forget their orders, obey the wrong players, and fight when told not to (#2614)
## Summary

Pet orders lived in two files with nothing enforcing which phase owned what: `PetOrderHandlers.cs` ran one-shot handlers inside the `ControlOrder` setter and `PetOrders.cs` ran `DoOrderXxx` every AI tick from `Obey`. Friend/Unfriend refusals repeated their message every tick, Rename froze the pet, Drop on a dead pet never ended, the loyalty drain bypassed the release handler, and the command issuer leaked through a public field that only some handlers cleared (#2613 fixed the Release casualty of that split; this finishes the job).

Every order now lives in one place, `PetOrders.cs`, with two named phases:

- **Issue** — `BaseAI.IssueOrder(order, previous, issuer, resuming, interruptedTarget)` runs once, synchronously, from the new `BaseCreature.SetControlOrder` funnel. It may only set state and emit (message, sound, reveal) and returns the order to rest in. The funnel loops to a fixed point, so transient orders (Drop, Friend, Unfriend, Transfer, Release, Rename, Stop, Patrol) resolve before the setter returns and can never rest.
- **Tick** — `DoOrderXxx` runs from `Obey` for the six restable orders only (None, Come, Guard, Attack, Stay, Follow). Anything else that arrives there came from an old save and falls back to the standing order.

The issuer is a parameter: `BaseCreature.IssueOrder(order, issuer, target)` is the entry for player commands (speech, context menu, targeting), a raw `ControlOrder = x` assignment is a system-issued order, and nothing has to remember to clear anything. A resumed Follow restores the mobile the standing Follow was following, never a transient's target.

Because the funnel is synchronous it also carries the order being interrupted, so an administrative command can hand control back to what the pet was doing without storing anything per creature.

## Era behaviour, with sources

Two publishes govern most of the questions here, and the inherited code matched neither exactly.

[**Publish 16**](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2002-2/publish-16-part-2-4-23rd-july/) (23 July 2002) — *"The 'stop' command will stop a pet from guarding, following, and attacking."* Stop cancels the current attack and leaves the pet idle but still reactive. That is what this branch does whenever the stand-down policy below is off, in every era. (The same publish's *"Friends will only be able to issue movement commands to pets"* is the rule already enforced by `IsFriendOrder`.)

[**Publish 51**](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2008-2/publish-51-26th-march/) (26 March 2008) lists it per command:

> Follow: The pet should follow. It will not attack anything, even if it is attacked.
>
> Come: The pet should come. It will not attack anything, even if it is attacked.
>
> Stay: The pet will stay where it is currently, and will not attack anything, even if it is attacked.
>
> Stop: The pet will stop attacking. It will not attack anything, even if it is attacked, and may wander.
>
> Guard: The pet should guard as it does currently.
>
> Kill/Attack: The pet will attack its target as it does currently.

The inherited rule covered Follow and Stay only, so a pet told to come fought back, and it could not cover Stop at all: Stop resolves to None rather than resting, and None is exactly the state the publish describes. `BaseAI.IsStandDownOrder` now names the set — Follow, Come, Stay, None — and both halves of `AggressiveAction` read it.

## Configuration

`taming.petsStandDownOnCommand` (default `Core.ML`) controls the Publish 51 behaviour. The publish has no step of its own on the expansion ladder — it lands between ML and SA, and Kingdom Reborn was a client rather than an expansion — so it keeps riding ML as before, and the setting carries the rest: the behaviour is popular well outside its era, so a shard on AOS that wants it sets the key, and one that does not clears it. `BaseCreature.StandsDownOnCommand` is virtual for a creature that should differ.

## Bugs fixed along the way

- Friend/Unfriend refusal spam (every tick until the next command); Rename freezing the pet; Drop on a dead or non-`CanDrop` pet freezing the pet.
- Loyalty-zero release skipping the name clear and the summoned kill; a released pet keeping its `Friends` list and its previous owner's standing order.
- A transferred pet still answering to the previous owner's friends; transfer playing the idle sound twice.
- `BaseTalisman` summons issued `Friend` with no target (*looks confused* forever, or young-player spam); they follow their owner.
- Speech: single-pet commands lost their name gate after #2232 (a bare "come" moved every pet in range; "all stay" issued Stay twice); the speech cases passed a hardcoded `isOwner: true`, so a pet friend could say "`<name>` drop" and dump the pack, or issue Come/Guard.
- GM "`<name>` obey" was unreachable for a controlled pet; context-menu Release and speech Release disagreed about the control roll (resolved by removing it from both, below).
- Login derived the standing order from proximity even for a pet saved on Stay (zeroing its post), and only recorded the derived order without issuing it, so a pet saved mid-transient idled after a restart.
- `Friends` mutations never marked the creature dirty for delta saves.
- A resumed standing Follow was left without a target and cancelled itself to idle on the next think — the same defect as #2616, fixed here by `IssueFollow(resuming)` restoring the remembered target.

## Behaviour changes a shard maintainer will notice

- Every player command reveals its issuer, including context-menu commands from a hidden owner. This restores the blanket reveal (RunUO reveals in every order arm) minus its bug: it revealed the **control master**, so a friend's command popped the owner wherever they stood. Speech already reveals through `Mobile.OnSaid`, so the practical change is the context menu, target picks and the release gump.
- Resumed/chained orders are silent (no idle sound when falling back after Drop, Stop, a refused Friend, etc.).
- **Administrative commands no longer call the pet off.** Drop, Friend, Unfriend and Rename keep the pet's combat posture and hand control back to the order they interrupted, target and all; the standing order is the fallback only when the interrupted order cannot resume (a transient, or an attack whose target died, left or hid). Resuming an attack does not repeat its aggression or replay its bark.
- **Friend and Unfriend no longer rewrite the standing order.** Previously a success pointed the pet at the new friend and made Follow its standing order, so a pet left on Stay silently became a Follow with its anchor cleared. Friending grants a permission and nothing else; the friend has movement commands and can ask the pet to follow.
- **Releasing a pet no longer rolls the control chance**, on either path. A refused roll cost 3 loyalty, and loyalty reaching zero releases the pet anyway, so refusing only converted a deliberate release into an involuntary one minutes later. Both paths gate on `CanBeControlledBy` instead: if you can command it, you can dismiss it.
- **The pet distraction roll is gone.** A pet on Follow had a 10% chance per damage callback of dropping the order and attacking whoever hit it, issued without consulting anything, so it overrode the stand-down policy a few hits after the aggression path had correctly ignored it. Its era gate was guesswork by its own comment's admission and no publish describes it; `CanBeDistracted`, `CheckDistracted`, both call sites and the `Golem` override are deleted. Pre-ML shards lose the mechanic.
- **A pet's follow pace moved off the think clock.** The AOS sprint wrote a bespoke `CurrentSpeed = 0.1`, which fused both clocks — discarding any configured `ActiveMoveSpeed`/`PassiveMoveSpeed` — and pinned a following pet's AI at 10 Hz even while standing still. `BaseCreature.FollowMoveSpeed` (virtual, AOS 0.1) now caps the resolved step delay while the pet is closing on its master, the same way herding does: nothing stored, and a creature configured faster keeps its own pace.
- Transfer with an invalid target is a refusal (resumes) instead of forcing Stay; the transfer combat gate rests on the aggressor lists and `NextCombatTime`.
- Stop with no standing order anchors the idle where the pet stands (a vendor-bought pet no longer wanders off unbounded).
- The old "master must be alive" bails in the handlers are gone; stand-down and sounds run for orphaned pets too. RunUO gates only on the master being null or deleted, and a living friend commanding a dead owner's pet could not previously call it out of a fight.
- Login: a pet at None near its master is issued Follow silently; a saved Stay/Follow/Guard is adopted as is.
- GM "all obey" only reaches wild creatures; a controlled pet must be named.
- Death still issues Follow with the idle sound, as RunUO did.

## Tests

`PetOrderTests` grew from 16 to 63, plus 13 in a new `PetRetaliationTests` for the Publish 51 matrix and 13 in `PetPacingTests` for the clocks. Together they cover order resolution, reveal on every entry path, release parity (player vs drain, summoned), transfer/friend refusals and successes, stand-down and war-mode invariants, the interrupted-order resume, speech gating and permissions, GM obey, login derivation, the load probe, and the retaliation matrix across eras and both damage callbacks. Whole project green: 845 `UOContent.Tests`, 869 `Server.Tests`.
2026-09-10 19:28:13 -07:00

853 lines
27 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/>. *
************************************************************************/
using System;
namespace Server.Mobiles;
public abstract partial class BaseAI
{
// Runtime-only: None after a load until PetLoginHandler derives it.
internal OrderType PersistentOrder { get; private set; } = OrderType.None;
public static bool IsRestableOrder(OrderType order) =>
order is OrderType.None or OrderType.Come or OrderType.Guard or OrderType.Attack or OrderType.Stay
or OrderType.Follow;
public static bool IsFriendOrder(OrderType order) =>
order is OrderType.Follow or OrderType.Stay or OrderType.Stop;
// Everything else stands the pet down before it runs.
private static bool KeepsCombatPosture(OrderType order) =>
order is OrderType.Attack or OrderType.Guard or OrderType.Drop or OrderType.Friend
or OrderType.Unfriend or OrderType.Rename;
// Publish 51: told any of these, a pet "will not attack anything, even if it is attacked".
// Stop resolves to None, which is its resting form ("and may wander").
public static bool IsStandDownOrder(OrderType order) =>
order is OrderType.Follow or OrderType.Come or OrderType.Stay or OrderType.None;
// Orders a dead bonded pet refuses.
public static bool IsDeadPetOrder(OrderType order) =>
order is OrderType.Guard or OrderType.Attack or OrderType.Transfer or OrderType.Drop;
// A targeted command overwrites ControlTarget; a resumed Follow restores it from here.
private Mobile _persistentTarget;
// The controlled-pet wander anchor (Home) is a pure function of the persistent command.
internal void SetPersistentOrder(OrderType order)
{
PersistentOrder = order;
_persistentTarget = order == OrderType.Follow ? Mobile.ControlTarget : null;
Mobile.Home = order is OrderType.Follow or OrderType.Guard ? Point3D.Zero : Mobile.Location;
}
// Adopt a saved standing order; Home and ControlTarget were saved with it.
internal void RestorePersistentOrder(OrderType order)
{
PersistentOrder = order;
_persistentTarget = order == OrderType.Follow ? Mobile.ControlTarget : null;
}
// The standing order is the fallback only for an interrupted order that cannot resume: a
// transient, or an attack whose target is gone.
private OrderType ResumeInterrupted(OrderType previous, Mobile interruptedTarget)
{
if (!IsRestableOrder(previous) ||
previous == OrderType.Attack && IsInvalidControlTarget(interruptedTarget))
{
return PersistentOrder;
}
Mobile.ControlTarget = interruptedTarget;
return previous;
}
// Resume the standing command without re-deriving it or re-anchoring Home.
private void ResumePersistentOrder() => Mobile.SetControlOrder(PersistentOrder, null, true);
/// <summary>
/// Issue phase. <paramref name="issuer"/> is the only mobile revealed (null = system-issued);
/// <paramref name="resuming"/> marks a fallback to the standing order. Returns the order to rest in.
/// </summary>
public virtual OrderType IssueOrder(
OrderType order, OrderType previous, Mobile issuer, bool resuming, Mobile interruptedTarget
)
{
if (Mobile.Deleted)
{
return order;
}
AITimer.Prod();
issuer?.RevealingAction();
// Dropping Warmode nulls Combatant through the Mobile setter, which would turn Attack's
// single Combatant write into a re-write (DoHarmful again) and flap Guard's war stance.
Mobile.FocusMob = null;
if (!KeepsCombatPosture(order))
{
Mobile.Warmode = false; // also nulls Combatant via the setter
Mobile.Combatant = null;
}
return order switch
{
OrderType.None => IssueNone(),
OrderType.Come => IssueCome(),
OrderType.Drop => IssueDrop(previous, interruptedTarget),
OrderType.Friend => IssueFriend(previous, interruptedTarget),
OrderType.Unfriend => IssueUnfriend(previous, interruptedTarget),
OrderType.Guard => IssueGuard(resuming),
OrderType.Attack => IssueAttack(resuming),
OrderType.Release => IssueRelease(),
OrderType.Stay => IssueStay(resuming),
OrderType.Stop => IssueStop(previous),
OrderType.Follow => IssueFollow(resuming),
OrderType.Transfer => IssueTransfer(),
OrderType.Rename => IssueRename(issuer, previous, interruptedTarget),
_ => PersistentOrder // Patrol and anything unimplemented
};
}
private OrderType IssueNone()
{
Mobile.ControlTarget = null;
Mobile.SetCurrentSpeedToPassive();
return OrderType.None;
}
private OrderType IssueCome()
{
Mobile.SetCurrentSpeedToActive();
return OrderType.Come;
}
private OrderType IssueStay(bool resuming)
{
Mobile.SetCurrentSpeedToPassive();
if (resuming)
{
Mobile.ControlTarget = null; // a transient's target does not carry over
}
else
{
SetPersistentOrder(OrderType.Stay); // anchors Home at the post
Mobile.PlaySound(Mobile.GetIdleSound());
}
return OrderType.Stay;
}
private OrderType IssueFollow(bool resuming)
{
Mobile.SetCurrentSpeedToActive();
if (resuming)
{
// the standing Follow's target, never a transient's
Mobile.ControlTarget = _persistentTarget?.Deleted == false ? _persistentTarget : Mobile.ControlMaster;
}
else
{
SetPersistentOrder(OrderType.Follow); // Home = Zero, remembers the target
Mobile.PlaySound(Mobile.GetIdleSound());
}
return OrderType.Follow;
}
private OrderType IssueGuard(bool resuming)
{
Mobile.Warmode = true; // the guard order opens in war stance
Mobile.SetCurrentSpeedToActive();
if (resuming)
{
Mobile.ControlTarget = null;
}
else
{
SetPersistentOrder(OrderType.Guard);
Mobile.PlaySound(Mobile.GetAttackSound());
Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name);
// ~1_NAME~ is now guarding you.
}
return OrderType.Guard;
}
private OrderType IssueAttack(bool resuming)
{
var target = Mobile.ControlTarget;
var valid = target?.Deleted == false && target.Alive;
Mobile.FocusMob = valid ? target : null;
Mobile.Combatant = valid ? target : null; // the one Combatant write of the Attack command
if (valid)
{
Action = ActionType.Combat;
}
Mobile.Warmode = true;
Mobile.SetCurrentSpeedToActive();
// A resumed attack is not a new command: no bark. The Combatant write above is
// idempotent (the setter early-outs unchanged), so its aggression is not repeated.
if (!resuming)
{
Mobile.PlaySound(Mobile.GetAttackSound());
}
return OrderType.Attack;
}
// Stop: Follow/Guard -> idle here; Stay -> keep the post; anything transient -> the standing order.
private OrderType IssueStop(OrderType previous)
{
Mobile.ControlTarget = null;
switch (previous)
{
case OrderType.Stay:
{
return OrderType.Stay; // resumed: anchor untouched
}
case OrderType.Follow:
case OrderType.Guard:
{
SetPersistentOrder(OrderType.None); // cancel the standing order; idle anchor = here
return OrderType.None;
}
default:
{
// No standing order: idle here, anchored (a Zero Home wanders without bounds).
if (PersistentOrder == OrderType.None)
{
SetPersistentOrder(OrderType.None);
}
return PersistentOrder;
}
}
}
private OrderType IssueDrop(OrderType previous, Mobile interruptedTarget)
{
if (!Mobile.IsDeadPet && Mobile.CanDrop)
{
this.DebugSayFormatted($"I am ordered to drop my items by {Mobile.ControlMaster?.Name ?? "Unknown"}.");
DropItems();
}
return ResumeInterrupted(previous, interruptedTarget);
}
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);
}
}
}
private OrderType IssueFriend(OrderType previous, Mobile interruptedTarget)
{
var from = Mobile.ControlMaster;
var to = Mobile.ControlTarget;
if (from?.Deleted != false)
{
return ResumeInterrupted(previous, interruptedTarget);
}
var youngFrom = from is PlayerMobile { Young: true };
var youngTo = to is PlayerMobile { Young: true };
if (youngFrom && !youngTo)
{
from.SendLocalizedMessage(502040);
// As a young player, you may not friend pets to older players.
return ResumeInterrupted(previous, interruptedTarget);
}
if (!youngFrom && youngTo)
{
from.SendLocalizedMessage(502041);
// As an older player, you may not friend pets to young players.
return ResumeInterrupted(previous, interruptedTarget);
}
if (to?.Deleted != false || from == to || !to.Player)
{
Mobile.PublicOverheadMessage(MessageType.Regular, 0x3B2, 502039);
// *looks confused*
return ResumeInterrupted(previous, interruptedTarget);
}
if (!from.CanBeBeneficial(to, true))
{
return ResumeInterrupted(previous, interruptedTarget);
}
if (from.HasTrade || to.HasTrade)
{
(from.HasTrade ? from : to).SendLocalizedMessage(1070947);
// You cannot friend a pet with a trade pending
return ResumeInterrupted(previous, interruptedTarget);
}
if (Mobile.IsPetFriend(to))
{
from.SendLocalizedMessage(1049691);
// That person is already a friend.
return ResumeInterrupted(previous, interruptedTarget);
}
if (!Mobile.AllowNewPetFriend)
{
from.SendLocalizedMessage(1005482);
// Your pet does not seem to be interested in making new friends right now.
return ResumeInterrupted(previous, interruptedTarget);
}
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);
return ResumeInterrupted(previous, interruptedTarget);
}
private OrderType IssueUnfriend(OrderType previous, Mobile interruptedTarget)
{
var from = Mobile.ControlMaster;
var to = Mobile.ControlTarget;
if (from?.Deleted != false || to?.Deleted != false || from == to || !to.Player)
{
Mobile.PublicOverheadMessage(MessageType.Regular, 0x3B2, 502039);
// *looks confused*
return ResumeInterrupted(previous, interruptedTarget);
}
if (!Mobile.IsPetFriend(to))
{
from.SendLocalizedMessage(1070953);
// That person is not a friend.
return ResumeInterrupted(previous, interruptedTarget);
}
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);
return ResumeInterrupted(previous, interruptedTarget);
}
private OrderType IssueTransfer()
{
if (Mobile.IsDeadPet)
{
return PersistentOrder;
}
var from = Mobile.ControlMaster;
var to = Mobile.ControlTarget;
if (from?.Deleted != false || to?.Deleted != false || from == to || !to.Player)
{
return PersistentOrder;
}
this.DebugSayFormatted($"Beginning transfer with {to.Name}");
var youngFrom = from is PlayerMobile { Young: true };
var youngTo = to is PlayerMobile { Young: true };
if (youngFrom && !youngTo)
{
from.SendLocalizedMessage(502040);
// As a young player, you may not friend pets to older players.
return PersistentOrder;
}
if (!youngFrom && youngTo)
{
from.SendLocalizedMessage(502041);
// As an older player, you may not friend pets to young players.
return PersistentOrder;
}
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~
return PersistentOrder;
}
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~
return PersistentOrder;
}
// The stand-down already cleared Combatant; the aggressor lists and the combat cooldown gate this.
if (Mobile.Aggressors.Count > 0 || Mobile.Aggressed.Count > 0 || Core.TickCount - Mobile.NextCombatTime < 0)
{
from.SendMessage("You can not transfer a pet while in combat.");
to.SendMessage("You can not transfer a pet while in combat.");
return PersistentOrder;
}
var fromState = from.NetState;
var toState = to.NetState;
if (fromState == null || toState == null)
{
return PersistentOrder;
}
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
return PersistentOrder;
}
var container = fromState.AddTrade(toState);
container.DropItem(new TransferItem(Mobile));
// Hold position while the trade window is open.
Mobile.PlaySound(Mobile.GetIdleSound());
Mobile.SetCurrentSpeedToPassive();
SetPersistentOrder(OrderType.Stay);
return OrderType.Stay;
}
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);
}
// SetControlMaster(null) assigns ControlOrder = None underneath; the funnel keeps that write.
private OrderType IssueRelease()
{
if (Mobile.Summoned)
{
Mobile.Kill();
// A vetoed death leaves the summon controlled; it keeps its standing order.
return Mobile.Deleted || !Mobile.Alive ? OrderType.None : PersistentOrder;
}
DebugSay("I have been released to the wild.");
if (!string.IsNullOrEmpty(Mobile.Name))
{
Mobile.Name = null;
}
Mobile.PlaySound(Mobile.GetIdleSound());
Mobile.ControlTarget = null;
Mobile.BondingBegin = DateTime.MinValue;
Mobile.OwnerAbandonTime = DateTime.MinValue;
Mobile.IsBonded = false;
// Nothing of the old master survives a re-tame.
Mobile.ClearPetFriends();
PersistentOrder = OrderType.None;
_persistentTarget = null;
Mobile.SetControlMaster(null);
var spawner = Mobile.Spawner;
if (spawner != null)
{
Mobile.Home = spawner.GetSpawnPosition(Mobile, spawner.Map);
Mobile.RangeHome = spawner.WalkingRange;
}
else
{
// No spawner: anchor here rather than path toward a stale stay anchor.
Mobile.Home = Mobile.Location;
Action = ActionType.Wander;
}
if (Mobile.DeleteOnRelease || Mobile.IsDeadPet)
{
Mobile.Delete();
}
else
{
Mobile.BeginDeleteTimer();
if (Mobile.CanDrop)
{
Mobile.DropBackpack();
}
}
return OrderType.None;
}
protected virtual OrderType IssueRename(Mobile issuer, OrderType previous, Mobile interruptedTarget)
{
var to = issuer ?? Mobile.ControlMaster;
if (Mobile.Summoned)
{
to?.SendMessage("You cannot rename a summoned creature.");
}
else
{
to?.SendMessage("Change name on pet health bar.");
}
return ResumeInterrupted(previous, interruptedTarget);
}
// Only restable orders arrive here; anything else is a pre-refactor save and resumes the standing order.
public virtual bool Obey()
{
if (Mobile.Deleted)
{
return false;
}
switch (Mobile.ControlOrder)
{
case OrderType.None:
{
return DoOrderNone();
}
case OrderType.Come:
{
return DoOrderCome();
}
case OrderType.Guard:
{
return DoOrderGuard();
}
case OrderType.Attack:
{
return DoOrderAttack();
}
case OrderType.Stay:
{
return DoOrderStay();
}
case OrderType.Follow:
{
return DoOrderFollow();
}
default:
{
ResumePersistentOrder();
return true;
}
}
}
public virtual bool DoOrderNone()
{
DebugSay("I currently have no orders.");
Mobile.Warmode = IsValidCombatant(Mobile.Combatant);
// A standing order is resumed through ResumePersistentOrder, never re-derived here.
WalkRandomIdle();
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, 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)
{
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, 1, 2);
}
}
public virtual bool DoOrderGuard()
{
var controlMaster = Mobile.ControlMaster;
if (Mobile.IsDeadPet || controlMaster?.Deleted != false)
{
return true;
}
var combatant = FindGuardTarget();
if (combatant != null)
{
this.DebugSayFormatted($"Attacking target: {combatant.Name}");
// Engage without leaving the Guard order so tags, recall handling, and retargeting persist.
Mobile.Combatant = combatant;
Mobile.FocusMob = combatant;
Action = ActionType.Combat;
Think();
}
else
{
this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}.");
// Stand down; a stale Warmode would skew the return pace.
Mobile.FocusMob = null;
Mobile.Warmode = false;
Mobile.Combatant = null;
var distance = (int)Mobile.GetDistanceToSqrt(controlMaster);
// Alert either way; FollowMoveSpeed caps the steps of the return itself.
Mobile.SetCurrentSpeedToActive();
if (distance > GuardRange)
{
WalkMobileRange(controlMaster, 1, 1, GuardRange);
}
else
{
WalkRandom(3, 1, 1);
}
}
return true;
}
public virtual bool DoOrderAttack()
{
if (Mobile.IsDeadPet)
{
return true;
}
if (IsInvalidControlTarget(Mobile.ControlTarget))
{
HandleInvalidControlTarget();
}
else
{
this.DebugSayFormatted($"Attacking target: {Mobile.ControlTarget?.Name}");
// OnAggressiveAction can swap Combatant; the commanded target wins.
if (Mobile.Combatant != Mobile.ControlTarget)
{
Mobile.Combatant = Mobile.ControlTarget;
}
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.");
ResumePersistentOrder();
// A resumed Guard engages through its own scan; other fallbacks chain an explicit Attack.
if (Mobile.ControlOrder == OrderType.Guard ||
Mobile.FightMode is not (FightMode.Closest or FightMode.Aggressor))
{
return;
}
var next = FindGuardTarget();
if (next != null)
{
Mobile.IssueOrder(OrderType.Attack, null, next);
this.DebugSayFormatted($"{next.Name} is still hostile! Engaging...");
Think();
}
}
/// <summary>
/// Selects the aggressor closest to the master. The current combatant is kept
/// unless a strictly closer one exists. Never mutates order state.
/// </summary>
private Mobile FindGuardTarget()
{
var controlMaster = Mobile.ControlMaster;
var anchor = controlMaster ?? Mobile;
var current = Mobile.Combatant;
var best = current != controlMaster && IsValidCombatant(current) ? current : null;
var bestDist = best?.GetDistanceToSqrt(anchor) ?? double.MaxValue;
foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception))
{
if (aggr == best || aggr == Mobile || aggr == controlMaster ||
aggr.IsDeadBondedPet || !aggr.Alive ||
aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster))
{
continue;
}
var dist = aggr.GetDistanceToSqrt(anchor);
if (dist < bestDist && Mobile.CanSee(aggr) && Mobile.InLOS(aggr))
{
best = aggr;
bestDist = dist;
}
}
var aggressors = controlMaster?.Aggressors;
if (aggressors != null)
{
for (var i = 0; i < aggressors.Count; i++)
{
var aggressor = aggressors[i].Attacker;
if (aggressor == best || aggressor?.Deleted != false || !aggressor.Alive ||
aggressor.IsDeadBondedPet || !Mobile.InRange(aggressor, Mobile.RangePerception))
{
continue;
}
var dist = aggressor.GetDistanceToSqrt(anchor);
if (dist < bestDist && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor))
{
best = aggressor;
bestDist = dist;
}
}
}
return best;
}
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"}.");
}
// 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)
{
DoMove(Mobile.GetDirectionTo(Mobile.Home));
}
return true;
}
}