## The bug Pet is set to follow. It gets attacked and defends itself (the order flips to `Attack`), or the player tells it to `come`. The player then says `stop`. The pet quietly stops following and starts idle-wandering — the player has to re-issue the order to get it back. `ResolveStop` clears `ControlTarget`, since the transient order that is ending owns it, and then resumes the standing order. Nothing restores a target, so the resumed `Follow` has none. `DoOrderFollow` checks for a target before anything else, finds none on the very next think, says "I have no one to follow" and cancels the order to `None`: ``` follow standing order=Follow target=set persist=Follow cur=0.2 => move=0.3 attacked (defends) order=Attack target=set persist=Follow cur=0.2 => move=0.3 stop issued order=Follow target=null persist=Follow cur=0.2 => move=0.3 next think order=None target=null persist=Follow cur=0.4 => move=0.9 ``` Two things go wrong at once: - `PersistentOrder` still reads `Follow` while `ControlOrder` is `None`, so the pet never recovers on its own. - The pace falls from the active clock to the passive one. A shard that tunes `SetMoveSpeed(active, passive)` sees a following pet drop from its active move speed to its passive one for no visible reason — which is how this surfaced. Era-independent. Guard is unaffected: `DoOrderGuard` works off `ControlMaster`, not `ControlTarget`. ## The fix A standing `Follow` does not always mean "follow the master" — a pet friend can point it elsewhere with `all follow me` / `*follow me` (0x163, 0x16C) or `*follow` plus a target pick. So `SetPersistentOrder` remembers the target the standing order was given, and `ResumePersistentOrder` restores it, falling back to the master only when that target is gone. The field is runtime-only, like `PersistentOrder` itself. Doing this in `ResumePersistentOrder` rather than `ResolveStop` also covers the Friend/Unfriend/Transfer resumes, where `ControlTarget` points at a third party and the pet would otherwise have resumed its follow on *them*. It matches what `HandleInvalidControlTarget` already does before its own resume. ## Tests `Stop_WhileAttacking_FallsBackToPersistentFollow` already existed and passed, because it asserted the resumed order without checking for a target or driving a think tick. Three tests added alongside it: - `Stop_WhileAttacking_ResumedFollowTargetsTheMaster` - `Stop_WhileAttacking_ResumedFollowKeepsAPetFriendAsItsTarget` — a friend's follow order is not hijacked back to the owner - `Stop_WhileAttacking_ResumedFollowSurvivesTheNextThink` — the one that catches the cancellation Each was confirmed to fail without the change (`Expected: Follow, Actual: None`; wrong target). Full suites green: 782 UOContent, 869 Server.
321 lines
11 KiB
C#
321 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using Server;
|
|
using Server.Mobiles;
|
|
using Xunit;
|
|
|
|
namespace UOContent.Tests.Mobiles.AI;
|
|
|
|
[Collection("Sequential UOContent Tests")]
|
|
public class PetOrderTests : IDisposable
|
|
{
|
|
// Track and delete every mobile we spawn so they don't linger in the shared static World
|
|
// and pollute other tests in this collection (e.g. Tracking's nearby-mobile scan).
|
|
private readonly List<Mobile> _created = new();
|
|
|
|
private (PlayerMobile master, PetTestStub pet) Spawn(Point3D masterLoc, Point3D petLoc)
|
|
{
|
|
var pair = PetTestSetup.SpawnControlledPet(masterLoc, petLoc);
|
|
_created.Add(pair.master);
|
|
_created.Add(pair.pet);
|
|
return pair;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var m in _created)
|
|
{
|
|
m?.Delete();
|
|
}
|
|
|
|
_created.Clear();
|
|
}
|
|
[Fact]
|
|
public void SetPersistentOrder_Stay_AnchorsHomeToCurrentLocation()
|
|
{
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1005, 1000, 0));
|
|
|
|
pet.AIObject.SetPersistentOrder(OrderType.Stay);
|
|
|
|
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
|
|
Assert.Equal(pet.Location, pet.Home);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetPersistentOrder_Follow_ClearsAnchor()
|
|
{
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1005, 1000, 0));
|
|
pet.Home = new Point3D(900, 900, 0); // stale anchor
|
|
|
|
pet.AIObject.SetPersistentOrder(OrderType.Follow);
|
|
|
|
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
|
|
Assert.Equal(Point3D.Zero, pet.Home);
|
|
}
|
|
|
|
[Fact]
|
|
public void Stop_WhileAttacking_FallsBackToPersistentFollow()
|
|
{
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0));
|
|
pet.ControlOrder = OrderType.Follow; // persistent = Follow
|
|
pet.ControlOrder = OrderType.Attack; // transient
|
|
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
|
|
|
|
pet.ControlOrder = OrderType.Stop;
|
|
|
|
Assert.Equal(OrderType.Follow, pet.ControlOrder); // resumed standing order
|
|
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
|
|
}
|
|
|
|
[Fact]
|
|
public void Stop_WhileAttacking_ResumedFollowTargetsTheMaster()
|
|
{
|
|
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
|
pet.ControlTarget = master;
|
|
pet.ControlOrder = OrderType.Follow; // persistent = Follow
|
|
pet.ControlOrder = OrderType.Attack; // transient
|
|
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
|
|
|
|
pet.ControlOrder = OrderType.Stop;
|
|
|
|
Assert.Equal(OrderType.Follow, pet.ControlOrder);
|
|
Assert.Equal(master, pet.ControlTarget);
|
|
}
|
|
|
|
[Fact]
|
|
public void Stop_WhileAttacking_ResumedFollowKeepsAPetFriendAsItsTarget()
|
|
{
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
|
var friend = new PlayerMobile(World.NewMobile);
|
|
friend.DefaultMobileInit();
|
|
friend.MoveToWorld(new Point3D(1002, 1000, 0), pet.Map);
|
|
_created.Add(friend);
|
|
|
|
var victim = new PetTestStub();
|
|
victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map);
|
|
_created.Add(victim);
|
|
|
|
pet.ControlTarget = friend; // a pet friend said "all follow me"
|
|
pet.ControlOrder = OrderType.Follow;
|
|
pet.ControlTarget = victim; // then "all kill" — the attack order takes the target
|
|
pet.ControlOrder = OrderType.Attack;
|
|
|
|
pet.ControlOrder = OrderType.Stop;
|
|
|
|
Assert.Equal(OrderType.Follow, pet.ControlOrder);
|
|
Assert.Equal(friend, pet.ControlTarget); // still the friend, not the owner
|
|
}
|
|
|
|
[Fact]
|
|
public void Stop_WhileAttacking_ResumedFollowSurvivesTheNextThink()
|
|
{
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
|
pet.ControlOrder = OrderType.Follow;
|
|
pet.ControlOrder = OrderType.Attack;
|
|
pet.ControlOrder = OrderType.Stop; // resumes Follow
|
|
|
|
pet.AIObject.Obey();
|
|
|
|
Assert.Equal(OrderType.Follow, pet.ControlOrder); // not dropped to idle
|
|
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
|
|
}
|
|
|
|
[Fact]
|
|
public void Stop_WhileFollowing_CancelsToIdleNone()
|
|
{
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0));
|
|
pet.ControlOrder = OrderType.Follow;
|
|
|
|
pet.ControlOrder = OrderType.Stop;
|
|
|
|
Assert.Equal(OrderType.None, pet.ControlOrder);
|
|
Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder);
|
|
Assert.Equal(pet.Location, pet.Home); // idle anchor = where stopped
|
|
}
|
|
|
|
[Fact]
|
|
public void Stop_WhileStaying_RemainsStayingAtOriginalPost()
|
|
{
|
|
var post = new Point3D(1005, 1005, 0);
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), post);
|
|
pet.ControlOrder = OrderType.Stay; // Home = post
|
|
Assert.Equal(post, pet.Home);
|
|
|
|
pet.ControlOrder = OrderType.Stop;
|
|
|
|
Assert.Equal(OrderType.Stay, pet.ControlOrder);
|
|
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
|
|
Assert.Equal(post, pet.Home); // post unchanged
|
|
}
|
|
|
|
[Fact]
|
|
public void Stay_ThenFollow_ThenStop_DoesNotReturnToOldStayAnchor() // report 2
|
|
{
|
|
var postA = new Point3D(1005, 1005, 0);
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), postA);
|
|
pet.ControlOrder = OrderType.Stay; // Home = A
|
|
pet.ControlOrder = OrderType.Follow; // Home cleared to Zero
|
|
pet.MoveToWorld(new Point3D(1050, 1050, 0), pet.Map); // walked to B
|
|
pet.ControlOrder = OrderType.Stop; // stop while following
|
|
|
|
Assert.NotEqual(postA, pet.Home); // never re-acquires A
|
|
Assert.Equal(pet.Location, pet.Home); // idles at B
|
|
}
|
|
|
|
[Fact]
|
|
public void AttackTargetLost_ResumesPersistentFollow()
|
|
{
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0));
|
|
pet.ControlOrder = OrderType.Follow; // persistent = Follow
|
|
pet.ControlOrder = OrderType.Attack;
|
|
pet.ControlTarget = null; // target gone
|
|
|
|
pet.AIObject.DoOrderAttack(); // invalid-target path
|
|
|
|
Assert.Equal(OrderType.Follow, pet.ControlOrder);
|
|
}
|
|
|
|
[Fact]
|
|
public void StayPet_AttacksThenTargetLost_ReturnsToOriginalPost() // report 1
|
|
{
|
|
var post = new Point3D(1005, 1005, 0);
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), post);
|
|
pet.ControlOrder = OrderType.Stay; // persistent = Stay, Home = post
|
|
pet.ControlOrder = OrderType.Attack;
|
|
pet.MoveToWorld(new Point3D(1060, 1060, 0), pet.Map); // chased far to the "corpse"
|
|
pet.ControlTarget = null;
|
|
|
|
pet.AIObject.DoOrderAttack();
|
|
|
|
Assert.Equal(OrderType.Stay, pet.ControlOrder);
|
|
Assert.Equal(post, pet.Home); // anchor still the original post, not the corpse
|
|
}
|
|
|
|
// NOTE: the test fixture does not load tile data, so Mobile.Move is blocked and Location
|
|
// never changes here. DoMoveImpl still sets Mobile.Direction before the (blocked) move,
|
|
// so an *attempted* wander is observable via Direction. The subjective wander cadence is
|
|
// covered by manual QA; these tests verify the gate/frozen wiring deterministically.
|
|
[Fact]
|
|
public void IdlePet_DoesNotAttemptToMove_WhileResting()
|
|
{
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0));
|
|
pet.ControlOrder = OrderType.Follow;
|
|
pet.ControlOrder = OrderType.Stop; // -> idle None
|
|
Assert.Equal(OrderType.None, pet.ControlOrder);
|
|
|
|
pet.ForceIdle = true; // CheckIdle() reports resting -> idle wander must be skipped
|
|
pet.Direction = Direction.North;
|
|
for (var i = 0; i < 40; i++)
|
|
{
|
|
pet.AIObject.DoOrderNone();
|
|
}
|
|
|
|
Assert.Equal(Direction.North, pet.Direction); // gated by CheckIdle -> never attempts a step
|
|
}
|
|
|
|
[Fact]
|
|
public void StayingPet_DoesNotAttemptToMove()
|
|
{
|
|
var post = new Point3D(1005, 1005, 0);
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), post);
|
|
pet.ControlOrder = OrderType.Stay;
|
|
pet.Direction = Direction.North;
|
|
|
|
for (var i = 0; i < 40; i++)
|
|
{
|
|
pet.AIObject.DoOrderStay();
|
|
}
|
|
|
|
Assert.Equal(Direction.North, pet.Direction); // frozen -> no wander attempts
|
|
}
|
|
|
|
[Fact]
|
|
public void ReleaseOrder_ClearsTheMaster_AndStartsTheDeleteCountdown()
|
|
{
|
|
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
|
pet.IsBonded = true;
|
|
var followers = master.Followers;
|
|
|
|
pet.ControlOrder = OrderType.Release;
|
|
|
|
Assert.False(pet.Controlled);
|
|
Assert.Null(pet.ControlMaster);
|
|
Assert.False(pet.IsBonded);
|
|
Assert.Equal(followers - pet.ControlSlots, master.Followers);
|
|
Assert.True(pet.PendingDeleteTimer?.Running);
|
|
Assert.Equal(pet.Location, pet.Home);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoyaltyRelease_ClearsTheMaster_AndStartsTheDeleteCountdown()
|
|
{
|
|
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
|
var followers = master.Followers;
|
|
|
|
// What the loyalty drain calls when loyalty reaches zero.
|
|
pet.AIObject.DoOrderRelease();
|
|
|
|
Assert.False(pet.Controlled);
|
|
Assert.Null(pet.ControlMaster);
|
|
Assert.Equal(followers - pet.ControlSlots, master.Followers);
|
|
Assert.True(pet.PendingDeleteTimer?.Running);
|
|
}
|
|
|
|
[Fact]
|
|
public void Release_WithoutSpawner_AnchorsHomeToCurrentLocation()
|
|
{
|
|
var loc = new Point3D(1010, 1010, 0);
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), loc);
|
|
pet.ControlOrder = OrderType.Stay; // sets Home to loc
|
|
pet.Home = new Point3D(800, 800, 0); // simulate a stale anchor
|
|
pet.Spawner = null;
|
|
|
|
pet.AIObject.DoOrderRelease();
|
|
|
|
Assert.Equal(loc, pet.Home); // released where it stands, not the stale point
|
|
}
|
|
|
|
[Fact]
|
|
public void Login_NearMaster_DerivesFollow()
|
|
{
|
|
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
|
Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder);
|
|
|
|
PetLoginHandler.DeriveFollowerOrders(master);
|
|
|
|
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
|
|
}
|
|
|
|
[Fact]
|
|
public void Login_FarFromMaster_DerivesStay()
|
|
{
|
|
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1040, 1000, 0));
|
|
|
|
PetLoginHandler.DeriveFollowerOrders(master);
|
|
|
|
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
|
|
}
|
|
|
|
[Fact]
|
|
public void Stop_WhileFollowing_CancelsToIdle_NonML()
|
|
{
|
|
var previous = Core.Expansion;
|
|
try
|
|
{
|
|
Core.Expansion = Expansion.SE; // pre-ML: Core.ML is false
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0));
|
|
pet.ControlOrder = OrderType.Follow;
|
|
|
|
pet.ControlOrder = OrderType.Stop;
|
|
|
|
// Stop/idle resolution is era-independent.
|
|
Assert.Equal(OrderType.None, pet.ControlOrder);
|
|
Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder);
|
|
Assert.Equal(pet.Location, pet.Home);
|
|
}
|
|
finally
|
|
{
|
|
Core.Expansion = previous;
|
|
}
|
|
}
|
|
}
|