ModernUO/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs
Kamron Batman 5d00149dd8
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>
2026-08-25 07:06:09 -07:00

140 lines
4.5 KiB
C#

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);
}
}