ModernUO/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs
Kamron Batman ab738d90af
fix: pet release never finished, summon master follows the pet, horse breeder and notoriety guards (#2613)
Pet and summon bugs found by the master-reference audit for #2592 (comments there have the full inventory). Independent of delta saves.

## Releasing a pet never finished

The Release order ran two half-releases that never met:

- The order handler cleared the master. That set `Controlled` to false, so `Obey` never ran again and the think-side `DoOrderRelease` (re-home, three-day delete timer, backpack drop) was dead code: a released pet kept its pack and never despawned.
- The loyalty drain called `DoOrderRelease` directly, so a pet whose loyalty hit zero got the countdown and dropped its pack but kept its master and its owner's follower slots, the opposite of the code comment's intent.

`DoOrderRelease` is now the whole release (targets, bonding, `SetControlMaster(null)`, re-home, delete or countdown, pack drop) and runs once, synchronously, from the handler or the loyalty drain. Summons still die on release, as before. Two tests pin both entry points: master cleared, follower slots returned, countdown running, home anchored.

This is the one place the `PetOrders` / `PetOrderHandlers` split bit; the wider audit of that duplication is a separate task.

## Summon master follows the pet

Transfer, stable claim, GM "obey" and Ball of Summoning copied `SummonMaster` only when `Summoned`, so a talisman summon (`Summoned` is false, `SummonMaster` set) kept its original summoner after a transfer: two different masters on one creature, with the original summoner's area spells still exempting it. They now mirror the summon master whenever it is set. Jail stabling cleared only `ControlMaster`, so a jailed talisman summon kept charging the summoner's follower slots; it now clears both, like the stable master and auto-stable already do.

## Small ones

- The faction horse breeder set `Controlled`/`ControlMaster` directly; it now goes through `SetControlMaster` and tells the buyer why it refused (1049607) instead of silently deleting the horse.
- `Notoriety` dereferenced `SummonMaster` on a summoned creature without a null check.

## Tests

UOContent.Tests 778 green (two new).
2026-09-06 16:12:29 -07:00

268 lines
9.2 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 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;
}
}
}