ModernUO/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs
Kamron Batman 4420872b22
fix: pet obedience pacing, stale AI wake rescheduling, and Guard order persistence through combat (#2594)
Closes #2593. Closes #2595.

Two related pet-AI fixes: the post-#2591 pacing/wake regression (#2593), and the guard order silently converting to Attack during combat (#2595). Root-cause analyses are in the issues.

## #2593 — pets follow slowly; stale AITimer wakes

**Why pets slowed:**
- The per-step budget grew from **half a think interval** (`CurrentSpeed * 500`) to the full RunUO-parity move table (`CurrentMoveSpeed * 1000`). Medium-bucket pets (Horse, Dog, most tamables): passiveMove **1.05s/step**.
- Pet order speed depended on stale `Warmode`: `HandleGuardOrder` set it once, but `OnCombatantChange` clears it whenever the combatant drops, so obedience ran active or passive **by combat history** — usually passive. Net: Guard/Come at ~1.05s/step (~2.1x slower than pre-#2591), vs a player running at 0.1–0.2s/step.
- The AITimer never rescheduled its pending wheel entry: the wheel reads `Interval` only after the next fire, so a speed-up or a fresh order (`Activate()` no-ops while running) waited out the stale wake — up to a full passive think, stacked on the residual move budget on Guard → Follow.

**What changed:**
- **Order handlers own obedience speed** (RunUO `OnCurrentOrderChanged`/`DoOrder*` parity, re-derived continuously): issuing a movement order (Come/Follow/Guard/Attack) sets the **active** think clock, resting orders (Stay/None/Transfer) set passive, and the guard/follow peaceful branches write **RunUO's AOS `CurrentSpeed = 0.1` sprint** — RunUO's guard else-branch had the identical write as follow. The bespoke 0.1 fuses to both clocks through #2591's existing classification, so `CurrentMoveSpeed` stays **pure herding + classification** with no obedience special case, and `DoMoveImpl`'s per-step flip skips obeying pets (their handler owns the pace) and loses its old follow-only 0.1 write. Combat still re-derives organically via warmode/combatant.
- **`AITimer`**: tracks the pending wake and reschedules (`Stop`, `Delay` = remaining, `Start`) when a speed-up or fresh order moves the earliest deadline up; changes inside a tick still flow through `ScheduleNext`. New `Prod()` wakes the AI immediately on player commands — including from a stopped timer, so stable claims no longer wait out the random construction stagger. Sector/spawn wakes keep the stagger. Spam-safe: a prodded think grants reaction, never action — steps/swings/casts/abilities are gated by their own budgets and timers.

The residual move budget is deliberately **not** cleared on order change — that would let order-spam macros grant free steps. Deadline changes reschedule the timer; rate changes take effect at the next deadline computation.

## #2595 — Guard order converts to Attack during combat

**Why:** `FindCombatant()` set `ControlOrder = OrderType.Attack` when engaging, so a guarding pet left the Guard order for the whole fight: OPL tags wiped (pet `1080078` + master `501129`), no retargeting (`DoOrderAttack` locks its target), `TeleportPets` left the pet behind on recall/gate, and every engage→kill→resume cycle replayed the guard flourish.

**What changed:**
- **`FindGuardTarget()`** (was `FindCombatant`): a pure selector — prefers the aggressor **closest to the master** (RunUO guard parity, dynamic retargeting to protect the owner), keeps the current combatant unless a strictly closer one exists, and never mutates order state. `DoOrderGuard` engages through it while **staying in Guard** the whole fight.
- **Persistent-order semantics** (the ModernUO improvement over RunUO): an explicit `all attack` completes → `ResumePersistentOrder()` returns to Guard → the guard scan engages remaining threats in-order. The Attack-chaining fallback (`FightMode.Closest/Aggressor`) now applies only to non-guard persistent orders. Resuming Guard no longer replays the sound/"is now guarding you" message.
- **Peaceful guard stands down deterministically** (`Warmode`/`Combatant`/`FocusMob` cleared) and returns to the master at the RunUO sprint (see above); at the master's side it stays organically active.
- **`WalkMobileRange` honors the caller's run flag** (the internal hardcoded `dist > 5` gate silently overrode it). Run is animation-only server-side; the only callers passing anything but `false` — follow, guard, clone — gate on their own thresholds.

## Resulting behavior (Medium-bucket pet)

| Scenario | Broken | This PR |
|---|---|---|
| Guard trailing master (AOS) | ~1.05s/step, think-grid quantized | 0.1s/step sprint (RunUO parity), smooth move wakes |
| Guard during combat | order flips to Attack; tags lost; no retarget; left behind on recall | stays Guard; retargets to master's closest aggressor; teleports with master |
| `all attack` while guarding | resume spams guard flourish per kill; chains into Attack | resumes Guard silently; guard scan takes over |
| Come / friend-follow | 1.05s/step | activeMove 0.45s/step (≈ pre-#2591 feel) |
| Guard → Follow reaction | up to ~1.5s dead time | think within one wheel turn |
| Follow master (AOS sprint) | 0.1s/step | 0.1s/step (unchanged) |
| Wild creature chase | RunUO-parity move table | unchanged |

Also documents two contracts this work leaned on: the `ControlOrder` setter deliberately fires on every assignment (a reissued order is a command — retarget/break-off/re-anchor), and `OnThink`/`MonsterAbility` must be excess-call tolerant (`dev-docs/content-patterns.md` § OnThink: the excess-call contract).

## Testing

- Full suite passes (1570: 837 Server + 733 UOContent).
- `PetPacingTests`: order-issue think-clock parity, follow-master sprint via Obey, guard organically active at the master's side, combat-chase and herding boundaries, plus two deterministic timer-wheel tests (8ms-lockstep slicing) proving a fresh order and a mid-wait speed-up wake the AI promptly.
- `GuardOrderTests`: engage keeps the Guard order; retargets to the aggressor closest to the master; explicit attack resumes Guard without chaining into Attack; peaceful guard stands down. Setup self-validates LOS/terrain.
- `GuardFollowTests`: guard-following registers a move intent, steps toward the master, sprints at 0.1 under AOS (per-step flip must not undo it), and runs active pre-AOS.
- All behavioral tests were written first and failed for the documented reasons.
2026-08-30 16:39:29 -07:00

287 lines
8.5 KiB
C#

/*************************************************************************
* 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.ControlTarget = null;
Mobile.FocusMob = null;
Mobile.Warmode = false;
Mobile.Combatant = null;
Mobile.PlaySound(Mobile.GetIdleSound());
Mobile.BondingBegin = DateTime.MinValue;
Mobile.OwnerAbandonTime = DateTime.MinValue;
Mobile.IsBonded = false;
Mobile.SetControlMaster(null);
_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.");
}
}
}