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>
140 lines
4.5 KiB
C#
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);
|
|
}
|
|
}
|