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`.
This commit is contained in:
Kamron Batman 2026-09-10 19:28:13 -07:00 • committed by GitHub
parent f7caff8ad3
commit f606225f47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1841 additions and 792 deletions

View file

@ -42,7 +42,6 @@ public class ConfirmReleaseGump : StaticGump<ConfirmReleaseGump>
return;
}
_pet.ControlTarget = null;
_pet.ControlOrder = OrderType.Release;
_pet.IssueOrder(OrderType.Release, _from);
}
}

View file

@ -502,7 +502,7 @@ public partial class BaseTalisman : Item, IAosItem
);
mob.Summoned = false;
mob.ControlOrder = OrderType.Friend;
mob.IssueOrder(OrderType.Follow, null, from);
_creature = mob;
}

View file

@ -612,6 +612,24 @@ public abstract partial class BaseAI
Mobile.ControlTarget == Mobile.ControlMaster &&
Mobile.Combatant == null;
// Following its master, or guarding from outside guard range. FollowMoveSpeed caps the step
// delay while this holds.
public bool IsPacingToMaster()
{
if (!Mobile.Controlled || Mobile.Combatant != null)
{
return false;
}
return Mobile.ControlOrder switch
{
OrderType.Follow => Mobile.ControlTarget == Mobile.ControlMaster,
OrderType.Guard => Mobile.ControlMaster?.Deleted == false &&
(int)Mobile.GetDistanceToSqrt(Mobile.ControlMaster) > GuardRange,
_ => false
};
}
// A pet executing a movement order outside combat; its order handler owns its speed.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool IsObeyingMoveOrder() =>

View file

@ -26,6 +26,9 @@ namespace Server.Mobiles;
public abstract partial class BaseAI
{
// How far a guarding pet may drift from its master before it closes the gap.
internal const int GuardRange = 3;
// Last-known-position tracking: recorded while the combatant is in LOS; drives the
// guard-time investigation and the instant re-engage.
private const int GuardGraceDuration = 10_000;
@ -35,7 +38,6 @@ public abstract partial class BaseAI
private ActionType _action;
public long _nextDetectHidden;
public DateTime _lastOrder = DateTime.MinValue;
public Mobile _commandIssuer;
private Mobile _lkpTarget;
private Point3D _lkpLocation;
@ -160,15 +162,7 @@ public abstract partial class BaseAI
if (Mobile.CheckControlChance(from))
{
Mobile.ControlTarget = target;
Mobile.ControlOrder = order;
if (order == OrderType.Attack)
{
Mobile.FocusMob = target;
Mobile.Combatant = target;
Action = ActionType.Combat;
}
Mobile.IssueOrder(order, from, target);
}
}
@ -188,7 +182,7 @@ public abstract partial class BaseAI
return false;
}
if (isFriend && order is not (OrderType.Follow or OrderType.Stay or OrderType.Stop))
if (isFriend && !IsFriendOrder(order))
{
return false;
}

View file

@ -47,7 +47,7 @@ internal sealed class InternalEntry : ContextMenuEntry
return from.CheckAlive() && bc != null && !bc.Deleted && bc.Controlled;
}
private bool IsInvalidOrderForDeadPet(BaseCreature bc) => bc.IsDeadPet && _order is OrderType.Guard or OrderType.Attack or OrderType.Transfer or OrderType.Drop;
private bool IsInvalidOrderForDeadPet(BaseCreature bc) => bc.IsDeadPet && BaseAI.IsDeadPetOrder(_order);
private static bool IsOwnerOrFriend(Mobile from, BaseCreature bc, out bool isFriend)
{
@ -56,7 +56,7 @@ internal sealed class InternalEntry : ContextMenuEntry
return isOwner || isFriend;
}
private bool IsInvalidOrderForFriend(bool isFriend) => isFriend && _order is not (OrderType.Follow or OrderType.Stay or OrderType.Stop);
private bool IsInvalidOrderForFriend(bool isFriend) => isFriend && !BaseAI.IsFriendOrder(_order);
private void HandleOrder(Mobile from, BaseCreature bc)
{
@ -99,9 +99,16 @@ internal sealed class InternalEntry : ContextMenuEntry
private void HandleReleaseOrder(Mobile from, BaseCreature bc)
{
// No roll: a refused one would only drain loyalty toward the involuntary release the
// drain performs anyway. Whoever can command the creature may dismiss it.
if (!bc.CanBeControlledBy(from))
{
return;
}
if (bc.Summoned)
{
HandleDefaultOrder(from, bc);
bc.IssueOrder(OrderType.Release, from);
return;
}
@ -112,8 +119,7 @@ internal sealed class InternalEntry : ContextMenuEntry
{
if (bc.CheckControlChance(from))
{
bc.ControlTarget = null;
bc.ControlOrder = _order;
bc.IssueOrder(_order, from);
}
}
}

View file

@ -48,16 +48,23 @@ public abstract partial class BaseAI
}
}
if (Mobile.Controlled && Mobile.Commandable)
// Staff first, so "<name> obey" reaches a controlled pet.
if (e.Mobile.AccessLevel >= AccessLevel.GameMaster && HandleGMCommands(e))
{
AllOnSpeechPet(e);
NamedOnSpeechPet(e);
return;
}
if (e.Mobile.AccessLevel >= AccessLevel.GameMaster)
if (Mobile.Controlled && Mobile.Commandable)
{
HandleGMCommands(e);
// Exactly one handler per utterance: named addresses this pet, "all" every pet in range.
if (WasNamed(e.Speech))
{
NamedOnSpeechPet(e);
}
else
{
AllOnSpeechPet(e);
}
}
}
@ -158,7 +165,7 @@ public abstract partial class BaseAI
{
case 0x164: // all come
{
HandleComeCommand(e.Mobile, true);
HandleComeCommand(e.Mobile, isOwner);
break;
}
case 0x165: // all follow
@ -169,7 +176,7 @@ public abstract partial class BaseAI
case 0x166: // all guard
case 0x16B: // all guard me
{
HandleGuardCommand(e.Mobile, true);
HandleGuardCommand(e.Mobile, isOwner);
break;
}
case 0x167: // all stop
@ -180,7 +187,7 @@ public abstract partial class BaseAI
case 0x168: // all kill
case 0x169: // all attack
{
HandleAttackCommand(e.Mobile, true);
HandleAttackCommand(e.Mobile, isOwner);
break;
}
case 0x16C: // all follow me
@ -230,12 +237,12 @@ public abstract partial class BaseAI
{
case 0x155: // *come
{
HandleComeCommand(e.Mobile, true);
HandleComeCommand(e.Mobile, isOwner);
break;
}
case 0x156: // *drop
{
HandleDropCommand(e.Mobile, true, e.Speech);
HandleDropCommand(e.Mobile, isOwner);
break;
}
case 0x15A: // *follow
@ -245,18 +252,18 @@ public abstract partial class BaseAI
}
case 0x15B: // *friend
{
HandleFriendCommand(e.Mobile, true, e.Speech);
HandleFriendCommand(e.Mobile, isOwner);
break;
}
case 0x15C: // *guard
{
HandleGuardCommand(e.Mobile, true);
HandleGuardCommand(e.Mobile, isOwner);
break;
}
case 0x15D: // *kill
case 0x15E: // *attack
{
HandleAttackCommand(e.Mobile, true);
HandleAttackCommand(e.Mobile, isOwner);
break;
}
case 0x161: // *stop
@ -271,12 +278,12 @@ public abstract partial class BaseAI
}
case 0x16D: // *release
{
HandleReleaseCommand(e.Mobile, true, e.Speech);
HandleReleaseCommand(e.Mobile, isOwner);
break;
}
case 0x16E: // *transfer
{
HandleTransferCommand(e.Mobile, true, e.Speech);
HandleTransferCommand(e.Mobile, isOwner);
break;
}
case 0x16F: // *stay
@ -330,9 +337,7 @@ public abstract partial class BaseAI
{
if (isOwner && Mobile.CheckControlChance(from))
{
_commandIssuer = from;
Mobile.ControlTarget = null;
Mobile.ControlOrder = OrderType.Come;
Mobile.IssueOrder(OrderType.Come, from);
}
}
@ -340,9 +345,7 @@ public abstract partial class BaseAI
{
if (isOwner && Mobile.CheckControlChance(from))
{
_commandIssuer = from;
Mobile.ControlTarget = null;
Mobile.ControlOrder = OrderType.Guard;
Mobile.IssueOrder(OrderType.Guard, from);
}
}
@ -350,9 +353,7 @@ public abstract partial class BaseAI
{
if (Mobile.CheckControlChance(from))
{
_commandIssuer = from;
Mobile.ControlTarget = target;
Mobile.ControlOrder = order;
Mobile.IssueOrder(order, from, target);
}
}
@ -360,25 +361,21 @@ public abstract partial class BaseAI
{
if (isOwner)
{
_commandIssuer = from;
BeginPickTarget(from, OrderType.Attack);
}
}
private void HandleDropCommand(Mobile from, bool isOwner, string speech)
private void HandleDropCommand(Mobile from, bool isOwner)
{
if (isOwner && !Mobile.IsDeadPet && !Mobile.Summoned && WasNamed(speech)
&& Mobile.CheckControlChance(from))
if (isOwner && !Mobile.IsDeadPet && !Mobile.Summoned && Mobile.CheckControlChance(from))
{
_commandIssuer = from;
Mobile.ControlTarget = null;
Mobile.ControlOrder = OrderType.Drop;
Mobile.IssueOrder(OrderType.Drop, from);
}
}
private void HandleFriendCommand(Mobile from, bool isOwner, string speech)
private void HandleFriendCommand(Mobile from, bool isOwner)
{
if (isOwner && WasNamed(speech) && Mobile.CheckControlChance(from))
if (isOwner && Mobile.CheckControlChance(from))
{
if (Mobile.Summoned || Mobile is GrizzledMare)
{
@ -398,29 +395,31 @@ public abstract partial class BaseAI
}
}
private void HandleReleaseCommand(Mobile from, bool isOwner, string speech)
private void HandleReleaseCommand(Mobile from, bool isOwner)
{
if (!isOwner)
{
return;
}
if (WasNamed(speech) && Mobile.CheckControlChance(from))
// No control roll: see InternalEntry.HandleReleaseOrder.
if (!Mobile.CanBeControlledBy(from))
{
if (!Mobile.Summoned)
{
from.SendGump(new ConfirmReleaseGump(from, Mobile));
}
else
{
Mobile.ControlOrder = OrderType.Release;
}
return;
}
if (Mobile.Summoned)
{
Mobile.IssueOrder(OrderType.Release, from);
return;
}
from.SendGump(new ConfirmReleaseGump(from, Mobile));
}
private void HandleTransferCommand(Mobile from, bool isOwner, string speech)
private void HandleTransferCommand(Mobile from, bool isOwner)
{
if (isOwner && !Mobile.IsDeadPet && WasNamed(speech) && Mobile.CheckControlChance(from))
if (isOwner && !Mobile.IsDeadPet && Mobile.CheckControlChance(from))
{
if (Mobile.Summoned || Mobile is GrizzledMare)
{
@ -440,18 +439,23 @@ public abstract partial class BaseAI
}
}
private void HandleGMCommands(SpeechEventArgs e)
private bool HandleGMCommands(SpeechEventArgs e)
{
this.DebugSayFormatted($"Command is from GM: {e.Mobile.Name}, Target: {Mobile.ControlTarget?.Name ?? "None or Unknown"}");
if (Mobile.FindMyName(e.Speech, true) && e.Speech.InsensitiveContains("obey"))
// "all obey" is for wild creatures; a controlled pet must be named.
if (!Mobile.FindMyName(e.Speech, !Mobile.Controlled) || !e.Speech.InsensitiveContains("obey"))
{
Mobile.SetControlMaster(e.Mobile);
if (Mobile.SummonMaster != null)
{
Mobile.SummonMaster = e.Mobile;
}
return false;
}
Mobile.SetControlMaster(e.Mobile);
if (Mobile.SummonMaster != null)
{
Mobile.SummonMaster = e.Mobile;
}
return true;
}
}

View file

@ -25,9 +25,8 @@ public static class PetLoginHandler
[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.
// PersistentOrder is runtime-only; ControlOrder and Home are saved. A pet saved mid-transient
// gets Follow near the master, else Stay.
public static void DeriveFollowerOrders(PlayerMobile master)
{
if (master?.AllFollowers == null)
@ -37,14 +36,30 @@ public static class PetLoginHandler
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)
if (follower is not BaseCreature { Controlled: true, Deleted: false } bc
|| bc.ControlMaster != master
|| bc.AIObject is not { } ai
|| ai.PersistentOrder != OrderType.None)
{
var near = bc.Map == master.Map && bc.GetDistanceToSqrt(master) <= FollowRange;
ai.SetPersistentOrder(near ? OrderType.Follow : OrderType.Stay);
continue;
}
var restored = bc.ControlOrder;
if (restored is OrderType.Stay or OrderType.Follow or OrderType.Guard)
{
ai.RestorePersistentOrder(restored);
continue;
}
var near = bc.Map == master.Map && bc.GetDistanceToSqrt(master) <= FollowRange;
var derived = near ? OrderType.Follow : OrderType.Stay;
// ControlTarget first: SetPersistentOrder records it as the Follow target, and a
// mid-Attack save still holds the victim.
bc.ControlTarget = near ? master : null;
ai.SetPersistentOrder(derived);
bc.SetControlOrder(derived, null, true);
}
}
}

View file

@ -1,280 +0,0 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: PetOrderHandlers.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
{
public virtual void OnCurrentOrderChanged(OrderType previous)
{
if (Mobile.Deleted || Mobile.ControlMaster?.Deleted != false)
{
return;
}
AITimer.Prod();
switch (Mobile.ControlOrder)
{
case OrderType.None:
{
HandleNoOrder();
break;
}
case OrderType.Come:
{
Mobile.SetCurrentSpeedToActive();
break;
}
case OrderType.Drop:
case OrderType.Friend:
case OrderType.Unfriend:
{
break;
}
case OrderType.Release:
{
HandleReleaseOrder();
break;
}
case OrderType.Stop:
{
// Stop is resolved into another order; it never rests as the active order.
ResolveStop(previous);
return;
}
case OrderType.Transfer:
{
HandleTransferOrder();
break;
}
case OrderType.Stay:
{
HandleStayOrder();
break;
}
case OrderType.Guard:
{
HandleGuardOrder();
break;
}
case OrderType.Attack:
{
HandleAttackOrder();
break;
}
case OrderType.Follow:
{
HandleFollowOrder();
break;
}
case OrderType.Rename:
{
HandleRenameOrder();
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()
{
Mobile.ControlTarget = null;
Mobile.FocusMob = null;
Mobile.Warmode = false;
Mobile.Combatant = null;
Mobile.SetCurrentSpeedToPassive();
}
private void HandleTransferOrder()
{
if (Mobile.ControlMaster?.Alive != true)
{
return;
}
_commandIssuer?.RevealingAction();
Mobile.FocusMob = null;
Mobile.Warmode = false;
Mobile.Combatant = null;
Mobile.SetCurrentSpeedToPassive();
Mobile.PlaySound(Mobile.GetIdleSound());
_commandIssuer = null;
}
private void HandleGuardOrder()
{
if (Mobile.ControlMaster?.Alive != true)
{
return;
}
_commandIssuer?.RevealingAction();
Mobile.FocusMob = null;
Mobile.Warmode = true;
Mobile.SetCurrentSpeedToActive();
// Resuming the persistent order must not replay the flourish.
if (!_resolvingOrder)
{
Mobile.PlaySound(Mobile.GetAttackSound());
Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name);
// ~1_NAME~ is now guarding you.
}
_commandIssuer = null;
}
private void HandleAttackOrder()
{
if (Mobile.ControlMaster?.Alive != true)
{
return;
}
_commandIssuer?.RevealingAction();
if (Mobile.ControlTarget != null &&
!Mobile.ControlTarget.Deleted &&
Mobile.ControlTarget.Alive)
{
Mobile.FocusMob = Mobile.ControlTarget;
Mobile.Combatant = Mobile.ControlTarget;
}
else
{
Mobile.FocusMob = null;
Mobile.Combatant = null;
}
Mobile.Warmode = true;
Mobile.SetCurrentSpeedToActive();
Mobile.PlaySound(Mobile.GetAttackSound());
_commandIssuer = null;
}
private void HandleFollowOrder()
{
if (Mobile.ControlMaster?.Alive != true)
{
return;
}
_commandIssuer?.RevealingAction();
Mobile.FocusMob = null;
Mobile.Warmode = false;
Mobile.Combatant = null;
Mobile.SetCurrentSpeedToActive();
Mobile.PlaySound(Mobile.GetIdleSound());
_commandIssuer = null;
}
private void HandleStayOrder()
{
if (Mobile.ControlMaster?.Alive != true)
{
return;
}
_commandIssuer?.RevealingAction();
Mobile.FocusMob = null;
Mobile.Warmode = false;
Mobile.Combatant = null;
Mobile.SetCurrentSpeedToPassive();
Mobile.PlaySound(Mobile.GetIdleSound());
_commandIssuer = null;
// Home (the stay anchor) is owned by SetPersistentOrder, not this handler.
}
private void HandleReleaseOrder()
{
if (Mobile.ControlMaster?.Alive != true)
{
return;
}
if (Mobile.Summoned)
{
Mobile.Kill();
return;
}
if (!string.IsNullOrEmpty(Mobile.Name))
{
Mobile.Name = null;
}
_commandIssuer?.RevealingAction();
Mobile.PlaySound(Mobile.GetIdleSound());
DoOrderRelease();
_commandIssuer = null;
}
public virtual void HandleRenameOrder()
{
if (Mobile.Summoned)
{
Mobile.ControlMaster?.SendMessage("You cannot rename a summoned creature.");
}
else
{
Mobile.ControlMaster?.SendMessage("Change name on pet health bar.");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -161,12 +161,11 @@ internal sealed partial class TransferItem : Item
_creature.SummonMaster = to;
}
_creature.ControlTarget = to;
_creature.ControlOrder = OrderType.Follow;
_creature.ClearPetFriends();
_creature.IssueOrder(OrderType.Follow, null, to);
_creature.BondingBegin = DateTime.MinValue;
_creature.OwnerAbandonTime = DateTime.MinValue;
_creature.IsBonded = false;
_creature.PlaySound(_creature.GetIdleSound());
var args = $"{from.Name}\t{_creature.Name}\t{to.Name}";
from.SendLocalizedMessage(1043253, args);

View file

@ -1059,9 +1059,6 @@ namespace Server.Mobiles
public virtual bool CanDestroyObstacles => false;
// OSI followers were distracted by attacks well into AoS; removed around ML.
public virtual bool CanBeDistracted => !Core.ML;
public override bool ShouldCheckStatTimers => false;
public virtual bool CanAngerOnTame => false;
@ -1103,6 +1100,13 @@ namespace Server.Mobiles
// (RunUO's forced 0.3, without its TransformMoveDelay inflation to 0.6).
private const double HerdingMoveSpeed = 0.3;
/// <summary>
/// Seconds per step while closing on the master under a standing order. A cap, not an
/// override: a creature configured faster keeps its own pace. 0 disables it.
/// </summary>
[CommandProperty(AccessLevel.GameMaster)]
public virtual double FollowMoveSpeed => Core.AOS ? 0.1 : 0;
[CommandProperty(AccessLevel.GameMaster)]
public IPoint2D TargetLocation
{
@ -1112,9 +1116,9 @@ namespace Server.Mobiles
/// <summary>
/// Resolved seconds per step: a verbatim active/passive <see cref="CurrentSpeed"/>
/// maps to the matching movement value; a bespoke pace (e.g. the pet-order 0.1 sprint)
/// stays fused to both clocks. A herded creature is always driven at
/// <see cref="HerdingMoveSpeed"/>.
/// maps to the matching movement value; a bespoke pace stays fused to both clocks. A
/// herded creature is always driven at <see cref="HerdingMoveSpeed"/>, and a pet
/// closing on its master is capped at <see cref="FollowMoveSpeed"/>.
/// </summary>
[CommandProperty(AccessLevel.GameMaster)]
public double CurrentMoveSpeed
@ -1126,17 +1130,26 @@ namespace Server.Mobiles
return HerdingMoveSpeed;
}
double speed;
if (_currentSpeed == _activeSpeed)
{
return _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed;
speed = _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed;
}
if (_currentSpeed == _passiveSpeed)
else if (_currentSpeed == _passiveSpeed)
{
return _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed;
speed = _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed;
}
else
{
speed = _currentSpeed;
}
return _currentSpeed;
var followSpeed = FollowMoveSpeed;
return followSpeed > 0 && AIObject?.IsPacingToMaster() == true
? Math.Min(followSpeed, speed)
: speed;
}
}
@ -1188,26 +1201,74 @@ namespace Server.Mobiles
}
}
// Fires on every assignment, not only changes: a reissued order is a command
// (retarget, break off combat, re-anchor Home). Handlers receive the previous order.
// Fires on every assignment, not only changes: a reissued order is a command (retarget, re-anchor).
// A raw assignment is system-issued; player commands go through IssueOrder.
[SerializableProperty(18, useField: nameof(_controlOrder))]
[SaveFlag(nameof(ShouldSerializeControlOrder))]
[CommandProperty(AccessLevel.GameMaster)]
public OrderType ControlOrder
{
get => _controlOrder;
set
set => SetControlOrder(value, null, false);
}
/// <summary>
/// Gives this pet a command. <paramref name="issuer"/> (null = system-issued) is the only mobile
/// revealed; <paramref name="target"/> replaces <see cref="ControlTarget"/> first.
/// </summary>
public void IssueOrder(OrderType order, Mobile issuer, Mobile target = null)
{
// The interrupted order owns this; BaseAI.ResumeInterrupted hands it back.
var interrupted = ControlTarget;
ControlTarget = target;
SetControlOrder(order, issuer, false, interrupted);
}
// Loops until the Issue phase returns an order that rests. `resuming` = falling back to
// the standing order: no re-derivation, no flourish.
internal void SetControlOrder(OrderType order, Mobile issuer, bool resuming) =>
SetControlOrder(order, issuer, resuming, ControlTarget);
internal void SetControlOrder(OrderType order, Mobile issuer, bool resuming, Mobile interruptedTarget)
{
var ai = AIObject;
var previous = _controlOrder;
_controlOrder = order;
if (ai != null)
{
var previous = _controlOrder;
_controlOrder = value;
for (var depth = 0; ; depth++)
{
var next = ai.IssueOrder(order, previous, issuer, resuming, interruptedTarget);
AIObject?.OnCurrentOrderChanged(previous);
// A nested assignment (SetControlMaster(null), Kill()) already resolved itself; it wins.
if (_controlOrder != order || next == order)
{
break;
}
InvalidateProperties();
System.Diagnostics.Debug.Assert(depth < 8, "pet order resolution did not converge");
_controlMaster?.InvalidateProperties();
this.MarkDirty();
if (depth >= 8)
{
// Non-converging override: rest at the standing order.
_controlOrder = ai.PersistentOrder;
break;
}
previous = order;
order = next;
issuer = null; // chained resolutions reveal nobody
resuming = true;
_controlOrder = order;
}
System.Diagnostics.Debug.Assert(Deleted || BaseAI.IsRestableOrder(_controlOrder), "a transient pet order rested");
}
InvalidateProperties();
_controlMaster?.InvalidateProperties();
this.MarkDirty();
}
[CommandProperty(AccessLevel.GameMaster)]
@ -1322,6 +1383,15 @@ namespace Server.Mobiles
public static bool BondingEnabled { get; private set; }
/// <summary>
/// Publish 51: a pet told to follow, come, stay or stop "will not attack anything, even
/// if it is attacked". Guard and attack are unaffected. The publish has no step of its own
/// on the expansion ladder, so it rides ML and the setting carries the rest.
/// </summary>
public static bool PetsStandDownOnCommand { get; private set; }
public virtual bool StandsDownOnCommand => PetsStandDownOnCommand;
public virtual bool IsBondable => BondingEnabled && !Summoned;
public virtual TimeSpan BondingDelay => TimeSpan.FromDays(7.0);
public virtual TimeSpan BondingAbandonDelay => TimeSpan.FromDays(1.0);
@ -1875,17 +1945,6 @@ namespace Server.Mobiles
BardPacified = false;
}
public virtual void CheckDistracted(Mobile from)
{
if (Utility.RandomDouble() < .10)
{
ControlTarget = from;
ControlOrder = OrderType.Attack;
Combatant = from;
Warmode = true;
}
}
public override void OnDamage(int amount, Mobile from, bool willKill)
{
if (BardPacified && (HitsMax - Hits) * 0.001 > Utility.RandomDouble())
@ -1927,21 +1986,11 @@ namespace Server.Mobiles
ReceivedHonorContext?.OnTargetDamaged(from, amount);
if (!willKill && CanBeDistracted && ControlOrder == OrderType.Follow)
{
CheckDistracted(from);
}
base.OnDamage(amount, from, willKill);
}
public virtual void OnDamagedBySpell(Mobile from, int damage)
{
if (CanBeDistracted && ControlOrder == OrderType.Follow)
{
CheckDistracted(from);
}
TriggerAbility(MonsterAbilityTrigger.TakeSpellDamage, from);
}
@ -2688,7 +2737,7 @@ namespace Server.Mobiles
if (AIObject != null)
{
if (!Core.ML || ct != OrderType.Follow && ct != OrderType.Stop && ct != OrderType.Stay)
if (!StandsDownOnCommand || !BaseAI.IsStandDownOrder(ct))
{
AIObject.OnAggressiveAction(aggressor);
}
@ -2714,11 +2763,10 @@ namespace Server.Mobiles
}
}
if (aggressor.ChangingCombatant && (_controlled || _summoned) &&
(ct == OrderType.Come || !Core.ML && ct == OrderType.Stay || ct is OrderType.Stop or OrderType.None or OrderType.Follow))
// Only reachable when the pet does not stand down: the orders above returned early.
if (aggressor.ChangingCombatant && (_controlled || _summoned) && BaseAI.IsStandDownOrder(ct))
{
ControlTarget = aggressor;
ControlOrder = OrderType.Attack;
IssueOrder(OrderType.Attack, null, aggressor);
}
else if (Combatant == null && !BardPacified)
{
@ -3419,8 +3467,7 @@ namespace Server.Mobiles
Mana = 0;
IsDeadPet = true;
ControlTarget = ControlMaster;
ControlOrder = OrderType.Follow;
IssueOrder(OrderType.Follow, null, ControlMaster);
ProcessDelta();
SendIncomingPacket();
@ -4159,6 +4206,7 @@ namespace Server.Mobiles
public static void Configure()
{
BondingEnabled = ServerConfiguration.GetSetting("taming.enableBonding", Core.LBR);
PetsStandDownOnCommand = ServerConfiguration.GetSetting("taming.petsStandDownOnCommand", Core.ML);
}
public void BeginDeleteTimer()
@ -4239,12 +4287,20 @@ namespace Server.Mobiles
public virtual void AddPetFriend(Mobile m)
{
Friends ??= new List<Mobile>();
Friends ??= [];
Friends.Add(m);
this.MarkDirty();
}
public virtual void RemovePetFriend(Mobile m) => Friends?.Remove(m);
public virtual void RemovePetFriend(Mobile m)
{
if (Friends?.Remove(m) == true)
{
this.MarkDirty();
}
}
public virtual void ClearPetFriends() => Friends = null; // generated setter marks dirty
public virtual bool IsFriend(Mobile m) =>
OppositionGroup?.IsEnemy(this, m) != true && m is BaseCreature c && _team == c._team
@ -5923,7 +5979,7 @@ namespace Server.Mobiles
c.Say(1043255, c.Name); // ~1_NAME~ appears to have decided that is better off without a master!
c.Loyalty = BaseCreature.MaxLoyalty;
c.AIObject.DoOrderRelease();
c.ControlOrder = OrderType.Release;
}
while (toRemove.Count > 0)

View file

@ -93,8 +93,6 @@ namespace Server.Mobiles
public override FoodType FavoriteFood => FoodType.None;
public override bool CanBeDistracted => false;
public override string DefaultName => "a golem";
public override bool DeleteOnRelease => true;

View file

@ -86,9 +86,7 @@ namespace Server.Mobiles
if (!Core.SE && Utility.RandomDouble() < 0.2 && attacker is BaseCreature c && c.Controlled &&
c.ControlMaster != null)
{
c.ControlTarget = c.ControlMaster;
c.ControlOrder = OrderType.Attack;
c.Combatant = c.ControlMaster;
c.IssueOrder(OrderType.Attack, null, c.ControlMaster);
}
}
}