fix: keep the Guard order through combat and sprint guard returns

A guarding pet's engagement path (FindCombatant) rewrote ControlOrder to
Attack, so guard silently converted to a plain kill order mid-fight (#2595):
the (guarding)/guarded OPL tags vanished, the pet stopped scanning for the
master's threats and never retargeted, recall/gate left it behind
(TeleportPets only takes Guard/Follow/Come), and every engage->kill->resume
cycle replayed the "is now guarding you" flourish. Return pacing also
depended on stale Warmode, leaving guard returns active or passive by combat
history.

- FindCombatant is now FindGuardTarget: a pure selector that prefers the
  aggressor closest to the master (RunUO guard parity) and keeps the current
  combatant unless a strictly closer one exists. DoOrderGuard engages through
  it without ever leaving the Guard order.
- HandleInvalidControlTarget resumes the persistent order first and chains
  the next aggressor into an explicit Attack only for non-guard fallbacks —
  an explicit "all attack" completes, returns to guarding, and the guard scan
  takes over. Resuming Guard no longer replays the sound/message flourish.
- The peaceful guard branch stands down (Warmode/Combatant/FocusMob cleared)
  so pacing is deterministic, and guard returns sprint at the follow pace
  (0.1s/step, AOS) on the move clock while the think cadence is untouched.
- WalkMobileRange honors the caller's run flag instead of a hardcoded
  distance-5 gate; follow/guard run animation now matches their actual pace.

Closes #2595

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-08-25 07:06:09 -07:00
parent 1ce2efb1ed
commit 5d00149dd8
No known key found for this signature in database
GPG key ID: 7D81DF26D9A5D94A
6 changed files with 265 additions and 53 deletions

View file

@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// Pins guard-order combat semantics (issue #2595): a guarding pet engages and fights
// without ever leaving the Guard order, retargets toward the master's closest aggressor,
// and stands down deterministically when there is nothing to guard against. The scene
// sits on the proven-open (1495..1500, 1600) Trammel segment from ApproachTargetTests;
// combat targets are adjacent so no pathfinding runs.
[Collection("Sequential UOContent Tests")]
public class GuardOrderTests : IDisposable
{
private readonly List<Mobile> _created = new();
private sealed class AggressorStub : Mobile
{
public AggressorStub() => Body = 0xC9;
}
public void Dispose()
{
foreach (var m in _created)
{
m?.Delete();
}
_created.Clear();
}
private (PlayerMobile master, PetTestStub pet) SpawnGuardingPet(out Map map, out int z)
{
map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out z, out _);
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
master.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
_created.Add(master);
var pet = new PetTestStub();
pet.MoveToWorld(new Point3D(1499, 1600, (sbyte)z), map);
pet.SetControlMaster(master);
_created.Add(pet);
pet.AIObject.AITimer?.Stop(); // drive manually
pet.ControlOrder = OrderType.Guard;
pet.AIObject.AITimer?.Stop(); // the order change restarts the timer
return (master, pet);
}
private AggressorStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking)
{
var aggr = new AggressorStub();
aggr.MoveToWorld(loc, pet.Map);
_created.Add(aggr);
// Guards the test setup itself: the scene must stay on open, LOS-clear terrain
// and the combatant assignment must not be vetoed.
Assert.True(pet.InLOS(aggr), $"no LOS from pet to aggressor at {loc}");
if (attacking != null)
{
aggr.Combatant = attacking;
Assert.Same(attacking, aggr.Combatant);
}
return aggr;
}
[Fact]
public void GuardEngage_KeepsGuardOrder()
{
var (master, pet) = SpawnGuardingPet(out _, out var z);
var aggr = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master);
pet.AIObject.Obey();
Assert.Same(aggr, pet.Combatant);
Assert.Equal(OrderType.Guard, pet.ControlOrder);
Assert.Equal(OrderType.Guard, pet.AIObject.PersistentOrder);
}
[Fact]
public void Guard_RetargetsToAggressorClosestToMaster()
{
var (master, pet) = SpawnGuardingPet(out _, out var z);
var far = SpawnAggressor(pet, new Point3D(1495, 1600, (sbyte)z), master);
var near = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master);
pet.Combatant = far; // already fighting the far aggressor
pet.AIObject.Obey();
Assert.Same(near, pet.Combatant); // defends the master, not the current fight
Assert.Equal(OrderType.Guard, pet.ControlOrder);
}
[Fact]
public void ExplicitAttack_ResumesGuard_WithoutChainingIntoAttack()
{
var (master, pet) = SpawnGuardingPet(out _, out var z);
// Explicit kill order on a target that then becomes invalid.
var victim = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), null);
pet.ControlTarget = victim;
pet.ControlOrder = OrderType.Attack;
victim.Hidden = true;
// A second aggressor is still after the master; FightMode.Closest would chain it.
var aggr2 = SpawnAggressor(pet, new Point3D(1497, 1600, (sbyte)z), master);
pet.AIObject.Obey(); // attack completes -> resume the persistent Guard
Assert.Equal(OrderType.Guard, pet.ControlOrder);
pet.AIObject.Obey(); // the guard scan engages the remaining aggressor in-order
Assert.Same(aggr2, pet.Combatant);
Assert.Equal(OrderType.Guard, pet.ControlOrder);
}
[Fact]
public void PeacefulGuard_StandsDown()
{
var (_, pet) = SpawnGuardingPet(out _, out _);
Assert.True(pet.Warmode); // the guard order opens in war stance
pet.AIObject.Obey(); // nothing to guard against
Assert.False(pet.Warmode);
Assert.Null(pet.Combatant);
Assert.Null(pet.FocusMob);
}
}

View file

@ -42,14 +42,50 @@ public class PetPacingTests : IDisposable
Assert.Equal(OrderType.Come, pet.ControlOrder); Assert.Equal(OrderType.Come, pet.ControlOrder);
Assert.Equal(0.4, pet.CurrentMoveSpeed); Assert.Equal(0.4, pet.CurrentMoveSpeed);
pet.ControlOrder = OrderType.Guard;
Assert.Equal(0.4, pet.CurrentMoveSpeed);
pet.ControlTarget = master; pet.ControlTarget = master;
pet.ControlOrder = OrderType.Follow; pet.ControlOrder = OrderType.Follow;
Assert.Equal(0.4, pet.CurrentMoveSpeed); Assert.Equal(0.4, pet.CurrentMoveSpeed);
} }
// A guarding pet with nothing to fight returns to its master at the follow sprint
// pace (RunUO guard parity) while its think cadence stays untouched.
[Fact]
public void GuardReturn_SprintsOnMoveClock()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.3, 0.9);
pet.SetCurrentSpeedToPassive();
pet.ControlOrder = OrderType.Guard; // fixture era is EJ: Core.AOS is true
Assert.Equal(0.1, pet.CurrentMoveSpeed);
Assert.Equal(0.4, pet.CurrentSpeed); // think clock unaffected
}
// Boundary guard: pre-AOS eras have no sprint — guard paces on the think clock.
[Fact]
public void GuardReturn_PreAOS_PacesThinkClock()
{
var previous = Core.Expansion;
try
{
Core.Expansion = Expansion.UOR;
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.3, 0.9);
pet.SetCurrentSpeedToPassive();
pet.ControlOrder = OrderType.Guard;
Assert.Equal(0.4, pet.CurrentMoveSpeed);
}
finally
{
Core.Expansion = previous;
}
}
// Boundary guard: a pet chasing a combatant keeps the move table. // Boundary guard: a pet chasing a combatant keeps the move table.
[Fact] [Fact]
public void CombatChasingPet_KeepsMoveTable() public void CombatChasingPet_KeepsMoveTable()

View file

@ -649,14 +649,12 @@ public abstract partial class BaseAI
{ {
var iCurrDist = (int)Mobile.GetDistanceToSqrt(m); var iCurrDist = (int)Mobile.GetDistanceToSqrt(m);
var shouldRun = run && iCurrDist > 5;
if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax) if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax)
{ {
return true; return true;
} }
if (!MoveTowardsOrAwayFrom(m, shouldRun, iCurrDist, iWantDistMax)) if (!MoveTowardsOrAwayFrom(m, run, iCurrDist, iWantDistMax))
{ {
return false; return false;
} }
@ -667,18 +665,19 @@ public abstract partial class BaseAI
return dist >= iWantDistMin && dist <= iWantDistMax; return dist >= iWantDistMin && dist <= iWantDistMax;
} }
// The caller's run flag is honored as-is: it only sets the client-side animation
// (server pace is the move budget), and the callers that pass anything but false —
// follow, guard, clone — gate it on their own distance thresholds.
private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax) private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax)
{ {
var shouldRun = run && iCurrDist > 5;
if (iCurrDist > iWantDistMax) if (iCurrDist > iWantDistMax)
{ {
// Too far: approach via the centralized progress-based primitive. // Too far: approach via the centralized progress-based primitive.
return ApproachTarget(m, shouldRun, iWantDistMax); return ApproachTarget(m, run, iWantDistMax);
} }
// Too close: back away. Retreat keeps the simple greedy behavior (out of scope). // Too close: back away. Retreat keeps the simple greedy behavior (out of scope).
if (DoMove(m.GetDirectionTo(Mobile, shouldRun), true)) if (DoMove(m.GetDirectionTo(Mobile, run), true))
{ {
Path = null; Path = null;
return true; return true;

View file

@ -163,9 +163,16 @@ public abstract partial class BaseAI
_commandIssuer?.RevealingAction(); _commandIssuer?.RevealingAction();
Mobile.FocusMob = null; Mobile.FocusMob = null;
Mobile.Warmode = true; Mobile.Warmode = true;
// Only a freshly issued command plays the flourish; resuming the persistent
// order after an explicit attack must not replay it after every kill.
if (!_resolvingOrder)
{
Mobile.PlaySound(Mobile.GetAttackSound()); Mobile.PlaySound(Mobile.GetAttackSound());
Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name);
// ~1_NAME~ is now guarding you. // ~1_NAME~ is now guarding you.
}
_commandIssuer = null; _commandIssuer = null;
} }

View file

@ -291,14 +291,15 @@ public abstract partial class BaseAI
return true; return true;
} }
FindCombatant(); var combatant = FindGuardTarget();
if (IsValidCombatant(Mobile.Combatant)) if (combatant != null)
{ {
var combatant = Mobile.Combatant;
this.DebugSayFormatted($"Attacking target: {combatant.Name}"); this.DebugSayFormatted($"Attacking target: {combatant.Name}");
// Engage without leaving the Guard order (#2595): the (guarding)/guarded
// tags persist, recall/gate keeps the pet, and the per-tick scan retargets
// toward the master's closest aggressor for the whole fight.
Mobile.Combatant = combatant; Mobile.Combatant = combatant;
Mobile.FocusMob = combatant; Mobile.FocusMob = combatant;
Action = ActionType.Combat; Action = ActionType.Combat;
@ -309,6 +310,12 @@ public abstract partial class BaseAI
{ {
this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}."); this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}.");
// Stand down deterministically: a stale Warmode otherwise leaves the return
// pace active or passive by combat history.
Mobile.FocusMob = null;
Mobile.Warmode = false;
Mobile.Combatant = null;
var distance = (int)Mobile.GetDistanceToSqrt(controlMaster); var distance = (int)Mobile.GetDistanceToSqrt(controlMaster);
if (distance > 3) if (distance > 3)
@ -359,67 +366,86 @@ public abstract partial class BaseAI
Mobile.ControlTarget = Mobile.ControlMaster; Mobile.ControlTarget = Mobile.ControlMaster;
ResumePersistentOrder(); ResumePersistentOrder();
if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor) // A resumed Guard engages through its own scan without ever leaving the order;
// only non-guard fallbacks chain the next aggressor through an explicit Attack.
if (Mobile.ControlOrder == OrderType.Guard ||
Mobile.FightMode is not (FightMode.Closest or FightMode.Aggressor))
{ {
FindCombatant(); return;
}
var next = FindGuardTarget();
if (next != null)
{
Mobile.ControlTarget = next;
Mobile.ControlOrder = OrderType.Attack;
Mobile.Combatant = next;
this.DebugSayFormatted($"{next.Name} is still hostile! Engaging...");
Think();
} }
} }
private void FindCombatant() /// <summary>
/// Selects the aggressor a pet should defend against, preferring whichever is
/// closest to the master (RunUO guard parity — a guarding pet retargets to protect
/// its owner). The current combatant is the baseline and is kept unless a strictly
/// closer aggressor exists. Pure selection: never mutates any order state.
/// </summary>
private Mobile FindGuardTarget()
{ {
var controlMaster = Mobile.ControlMaster; 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)) foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception))
{ {
if (!Mobile.CanSee(aggr) || aggr.IsDeadBondedPet || !aggr.Alive) if (aggr == best || aggr == Mobile || aggr == controlMaster ||
aggr.IsDeadBondedPet || !aggr.Alive ||
aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster))
{ {
continue; continue;
} }
var isAttackingPet = aggr.Combatant == Mobile; var dist = aggr.GetDistanceToSqrt(anchor);
var isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster;
if (isAttackingPet || isAttackingMaster) if (dist < bestDist && Mobile.CanSee(aggr) && Mobile.InLOS(aggr))
{ {
if (Mobile.InLOS(aggr)) best = aggr;
{ bestDist = dist;
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) var aggressors = controlMaster?.Aggressors;
{
for (var i = 0; i < controlMaster.Aggressors.Count; i++)
{
var aggressor = controlMaster.Aggressors[i].Attacker;
if (aggressor?.Deleted != false || !aggressor.Alive || aggressor.IsDeadBondedPet) 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; continue;
} }
if (Mobile.InRange(aggressor, Mobile.RangePerception) && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) var dist = aggressor.GetDistanceToSqrt(anchor);
if (dist < bestDist && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor))
{ {
Mobile.ControlTarget = aggressor; best = aggressor;
Mobile.ControlOrder = OrderType.Attack; bestDist = dist;
Mobile.Combatant = aggressor; }
}
}
this.DebugSayFormatted($"{aggressor.Name} recently attacked my master! Retaliating..."); return best;
Think();
return;
}
}
}
} }
public virtual bool DoOrderRelease() public virtual bool DoOrderRelease()

View file

@ -765,11 +765,15 @@ namespace Server.Mobiles
} }
// Obedience is never slowed by the wild-creature move table; combat // Obedience is never slowed by the wild-creature move table; combat
// chases (combatant set) keep it. // chases (combatant set) keep it. A guarding pet returns to its master
// at the follow sprint pace (RunUO guard parity) with its think cadence
// untouched.
if (Controlled && Combatant == null && if (Controlled && Combatant == null &&
ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard) ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard)
{ {
return _currentSpeed; return Core.AOS && ControlOrder == OrderType.Guard
? Math.Min(_currentSpeed, 0.1)
: _currentSpeed;
} }
return _currentSpeed == _activeSpeed ? ActiveMoveSpeed return _currentSpeed == _activeSpeed ? ActiveMoveSpeed