## Summary
Fixes two related pet-behavior bugs and the underlying design flaw behind both:
1. **Post-combat erratic** — after a pet killed its `all kill` target it milled around erratically at the kill site (or failed to return to the master) until the player issued `all follow`/`all stop`.
2. **`all stop` returns home** — a pet with a non-zero `Home` walked back toward that location on `all stop` (on ML; on non-ML the old `DoOrderStop` was a no-op, so the sighting there came from a residual `Stay`).
### Root cause
`ControlOrder` and the wild-creature `Home` field were overloaded to express several distinct ideas, mutated/read inconsistently across order transitions:
- `Home` doubled as the controlled-pet "stay anchor" (`HandleStayOrder` set `Home = Location`) but nothing cleared it when the pet left the staying state; `HandleStopOrder` was the only handler that never touched it.
- `DoOrderStop` had dropped RunUO's `Home = Location` re-anchor, so on ML it walked to a stale anchor.
- The post-combat fallback was a fragile `_lastPetOrder` hack in `DoOrderNone` that re-anchored a resumed `Stay` at the corpse.
- Controlled idle-wander bypassed the `CheckIdle()` rest gate that every non-controlled creature uses, so idling pets jittered every AI tick.
## Approach
Separate three concepts that were tangled together:
- **`ControlOrder`** — the active order (may be transient: Come/Attack/Drop).
- **Persistent command** (`PersistentOrder` ∈ `{None, Stay, Follow, Guard}`) — the standing directive a pet falls back to when a transient order completes. Runtime-only (not serialized; reset to `None` on load) and **derived from master proximity on login** (near → Follow, far → Stay).
- **Anchor** (`Home`) — a pure function of the persistent command, set only when that command changes (never on transient transitions or fallback-resume), so it can't go stale.
### Behavior
- **Stop** is resolved immediately from what the pet was doing: Attack/Come → resume the persistent command; Follow/Guard → cancel to idle where it stands; Stay → stay put.
- **Stay** holds its post (returns only if displaced, e.g. after a fight) — no shuffle.
- **Idle** (`None`) is a gentle wander routed through `CheckMove/CanMoveNow/CheckIdle`, so idling pets take the same 15–25s rest periods as other creatures, on both ML and non-ML.
- **Post-combat** the pet resumes its persistent command (a staying pet returns to its original post, not the corpse).
- **Release** without a spawner anchors where the pet stands instead of pathing to a stale anchor.
This restores the RunUO-intended behavior (verified against the RunUO reference) while fixing the ModernUO regressions.
## Tests
New `PetOrderTests` (13 deterministic xUnit tests) cover: anchor lifecycle, the full Stop truth table, report 1 (post-combat return to post), report 2 (no stale-anchor walk-home), frozen-Stay/gated-idle wiring, release fix, derive-on-login, and a non-ML spot-check. The subjective wander *feel* is covered by a manual-QA checklist in the implementation plan.
## Notes
- Engine project (`Projects/Server`) untouched; the one `BaseCreature.cs` change is the `ControlOrder` setter passing the previous order to `OnCurrentOrderChanged`.
- `DoOrderCome` keeps auto-converting to `Stay` on arrival, which under the new model cleanly means "come and hold near me."
- Commits in this PR are temporarily **unsigned** (the signing agent's passphrase cache expired mid-session); happy to re-sign / amend on request.
236 lines
8.1 KiB
C#
236 lines
8.1 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_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 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;
|
|
}
|
|
}
|
|
}
|