ModernUO/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs
Kamron Batman f8d2a2bacc
fix(ai): wild creatures no longer stand down when attacked (#2645)
## Symptom

Since #2614, on any shard with `taming.petsStandDownOnCommand` (default `Core.ML`), every wild creature hits `"I'm being attacked but my master told me not to fight."` when struck. Brigands still chase (acquisition is a separate path in the think loop) but the retaliation path is dead: no `OnAggressiveAction`, no `StopFlee`, no `ForceReacquire`, `Combatant` never set, and `Warmode` forced off on every hit.

## Root cause

#2614 correctly added `OrderType.None` to `BaseAI.IsStandDownOrder` — a stopped pet rests on `None` and Publish 51 says it must not fight back. But the gate in `BaseCreature.AggressiveAction` never asked whether anybody could have given the order. The old predicate (`ct != Follow && ct != Stop && ct != Stay`) had only excluded wild creatures by accident: `None` was not in its set, so nothing ever needed to spell the check out. A wild creature's `_controlOrder` is always `None`.

## Fix

Both halves of `AggressiveAction` now gate on `Controlled && ControlMaster != null && Commandable` — the same predicate every order entry point already uses (`OnSpeech`, context menu, `IsValidTarget`). Only a creature somebody can command has been told to stand down. That excludes, and lets fight back:

| Creature | Why it was standing down |
|---|---|
| Wild creature | rests on `None` |
| Energy vortex, blade spirits, animated weapon, animate dead | `Summoned` with a `SummonMaster` but never `Controlled`; rests on `None` |
| Familiar, talisman summon, escortee, mirror image | `Controlled` with a master but `Commandable => false`; sits on a system-issued `Follow` |

The last row is a deliberate behaviour change from #2614 for ML+ shards: a familiar or escortee on `Follow` no longer stands down when attacked. The publish speaks of commanded pets; a creature that cannot take an order was never told anything, and pre-ML it always fought back.

Commandable summons (Summon Creature, elementals, daemons) are `Controlled` with `ControlMaster == SummonMaster`, so they stand down exactly as pets do.

## Also: stop after stay

Found while testing: `all come` then `all stop` left the pet on `Stay`, ticking *"I have been ordered to stay"*. `come` rests into `Stay` on arrival (deliberate ModernUO divergence, kept), and #2614 had `IssueStop` keep a previous `Stay`. RunUO and ServUO never consult the previous order — `DoOrderStop` is "wander around here" (or `None` pre-ML) — and Publish 51 says a stopped pet *"may wander"*. `Stay` now joins `Follow`/`Guard` in `IssueStop`: stop cancels the standing order and the pet idles anchored where it stands. `Stop_WhileStaying_RemainsStayingAtOriginalPost` is replaced by `Stop_WhileStaying_CancelsToIdleNone` plus the `come, stop` repro.

## Tests

`PetRetaliationTests` gains one case per row above: `WildCreature_`, `UncontrolledSummon_`, `UncommandableCreature_Retaliates_UnderStandDown`. Each fails on main and passes here; full `UOContent.Tests` green (1058 passed).
2026-09-14 23:18:11 -07:00

1062 lines
39 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
}
// Publish 51: "Stop: ... may wander." A stay is a standing order like follow or guard;
// stop cancels it and the pet idles, anchored where it stands.
[Fact]
public void Stop_WhileStaying_CancelsToIdleNone()
{
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.None, pet.ControlOrder);
Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder);
Assert.Equal(pet.Location, pet.Home); // idle anchor = where stopped
}
// "all come" rests into Stay on arrival; "all stop" must not leave the pet at that post.
[Fact]
public void Come_ThenArrive_ThenStop_IdlesRatherThanStaying()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
Assert.Equal(OrderType.Come, pet.ControlOrder);
pet.AIObject.Obey(); // within 2 tiles -> Stay
Assert.Equal(OrderType.Stay, pet.ControlOrder);
pet.ControlOrder = OrderType.Stop;
Assert.Equal(OrderType.None, pet.ControlOrder);
Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder);
Assert.Equal(pet.Location, pet.Home);
}
[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 assigns when loyalty reaches zero.
pet.ControlOrder = OrderType.Release;
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.ControlOrder = OrderType.Release;
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 Login_RestoredStay_KeepsItsPostAnchor()
{
var post = new Point3D(1005, 1005, 0);
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), post);
pet.ControlOrder = OrderType.Stay; // Home = post
pet.ChangeAIType(pet.AI); // what AfterDeserialization does: fresh AI, PersistentOrder = None
Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder);
PetLoginHandler.DeriveFollowerOrders(master); // master within 12 tiles
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
Assert.Equal(post, pet.Home); // not zeroed by a proximity-derived Follow
}
[Fact]
public void Login_RestoredNone_NearMaster_IssuesFollow()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.ControlOrder = OrderType.Follow;
pet.ControlOrder = OrderType.Stop; // -> None, no standing order
pet.ChangeAIType(pet.AI);
master.Hidden = true;
PetLoginHandler.DeriveFollowerOrders(master);
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.Same(master, pet.ControlTarget);
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
Assert.True(master.Hidden); // system-issued: nobody revealed
}
[Fact]
public void Login_RestoredAttack_FarFromMaster_IssuesStay()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1040, 1000, 0));
pet.ControlOrder = OrderType.Attack; // rests with no valid target
pet.ChangeAIType(pet.AI);
PetLoginHandler.DeriveFollowerOrders(master);
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.Equal(pet.Location, pet.Home);
}
[Fact]
public void Login_RestoredAttack_NearMaster_FollowsTheMaster_NotTheVictim()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var victim = SpawnPlayer(new Point3D(1003, 1000, 0));
pet.IssueOrder(OrderType.Attack, master, victim); // saved mid-fight: ControlTarget = victim
pet.ChangeAIType(pet.AI); // post-load fresh AI
PetLoginHandler.DeriveFollowerOrders(master);
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.Same(master, pet.ControlTarget);
Assert.Equal(OrderType.Follow, 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;
}
}
// The parameterless ctor fully initializes a player; the Serial ctor leaves that to Deserialize.
private PlayerMobile SpawnPlayer(Point3D loc)
{
var pm = new PlayerMobile { Player = true };
pm.MoveToWorld(loc, Map.Felucca);
_created.Add(pm);
return pm;
}
// Administrative commands are not a change of what the pet is doing: it keeps fighting.
[Fact]
public void Drop_MidAttack_KeepsTheAttackAndItsTarget()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var victim = new PetTestStub();
victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map);
_created.Add(victim);
pet.ControlOrder = OrderType.Follow; // standing order
pet.IssueOrder(OrderType.Attack, master, victim);
pet.IssueOrder(OrderType.Drop, master);
Assert.Equal(OrderType.Attack, pet.ControlOrder);
Assert.Same(victim, pet.ControlTarget);
Assert.Same(victim, pet.Combatant);
}
[Fact]
public void Rename_MidAttack_KeepsTheAttack()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var victim = new PetTestStub();
victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map);
_created.Add(victim);
pet.ControlOrder = OrderType.Follow;
pet.IssueOrder(OrderType.Attack, master, victim);
pet.IssueOrder(OrderType.Rename, master);
Assert.Equal(OrderType.Attack, pet.ControlOrder);
Assert.Same(victim, pet.ControlTarget);
}
[Fact]
public void FriendRefusal_MidAttack_KeepsTheAttack()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend); // already a friend -> refusal
var victim = new PetTestStub();
victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map);
_created.Add(victim);
pet.ControlOrder = OrderType.Follow;
pet.IssueOrder(OrderType.Attack, master, victim);
pet.IssueOrder(OrderType.Friend, master, friend);
Assert.Equal(OrderType.Attack, pet.ControlOrder);
Assert.Same(victim, pet.ControlTarget);
}
// Nothing to resume into: the interrupted attack's target is gone.
[Fact]
public void Drop_MidAttack_WithTheTargetGone_FallsBackToTheStandingOrder()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var victim = new PetTestStub();
victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map);
_created.Add(victim);
pet.ControlOrder = OrderType.Follow;
pet.IssueOrder(OrderType.Attack, master, victim);
victim.Delete();
pet.IssueOrder(OrderType.Drop, master);
Assert.Equal(OrderType.Follow, pet.ControlOrder);
}
// Resuming an attack must not re-run the aggression that ordering it performed.
[Fact]
public void Drop_MidAttack_DoesNotRepeatTheHarm()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var victim = new PetTestStub();
victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map);
_created.Add(victim);
pet.ControlOrder = OrderType.Follow;
pet.IssueOrder(OrderType.Attack, master, victim);
var aggressors = victim.Aggressors.Count;
pet.IssueOrder(OrderType.Drop, master);
Assert.Equal(aggressors, victim.Aggressors.Count);
}
[Fact]
public void Friend_Refused_RestsAtPersistentOrder_AndObeyDoesNotRepeat()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.ControlOrder = OrderType.Follow; // persistent = Follow
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend); // already a friend -> refusal 1049691
pet.IssueOrder(OrderType.Friend, pet.ControlMaster, friend);
Assert.Equal(OrderType.Follow, pet.ControlOrder); // never rests at Friend
Assert.True(BaseAI.IsRestableOrder(pet.ControlOrder));
// Obey must not repeat the refusal.
pet.AIObject.Obey();
Assert.Equal(OrderType.Follow, pet.ControlOrder);
}
[Fact]
public void Unfriend_OfNonFriend_RestsAtPersistentOrder()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.ControlOrder = OrderType.Stay; // persistent = Stay
var stranger = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.IssueOrder(OrderType.Unfriend, pet.ControlMaster, stranger);
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
}
[Fact]
public void Friend_Accepted_LeavesTheStandingOrderAlone()
{
var post = new Point3D(1001, 1000, 0);
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), post);
pet.ControlOrder = OrderType.Stay; // the owner's standing order, anchored at the post
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.IssueOrder(OrderType.Friend, master, friend);
Assert.True(pet.IsPetFriend(friend));
Assert.Equal(OrderType.Stay, pet.ControlOrder); // still staying
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder); // owner's order not rewritten
Assert.Equal(post, pet.Home); // and not re-anchored
}
[Fact]
public void Unfriend_Accepted_LeavesTheStandingOrderAlone()
{
var post = new Point3D(1001, 1000, 0);
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), post);
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend);
pet.ControlOrder = OrderType.Stay;
pet.IssueOrder(OrderType.Unfriend, master, friend);
Assert.False(pet.IsPetFriend(friend));
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
Assert.Equal(post, pet.Home);
}
[Fact]
public void Rename_RestsAtARestableOrder()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.ControlOrder = OrderType.Follow;
pet.IssueOrder(OrderType.Rename, master);
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.True(BaseAI.IsRestableOrder(pet.ControlOrder));
}
[Fact]
public void Drop_OnAPetThatCannotDrop_StillResolves()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.ControlOrder = OrderType.Stay;
pet.IsDeadPet = true; // refuses to drop
pet.IssueOrder(OrderType.Drop, master);
Assert.Equal(OrderType.Stay, pet.ControlOrder);
}
[Fact]
public void Stop_ResolvesToARestableOrder_FromEveryPrevious()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
OrderType[] previousOrders = [OrderType.Come, OrderType.Attack, OrderType.Guard, OrderType.Follow, OrderType.Stay, OrderType.None];
for (var i = 0; i < previousOrders.Length; i++)
{
pet.ControlOrder = previousOrders[i];
pet.ControlOrder = OrderType.Stop;
Assert.True(BaseAI.IsRestableOrder(pet.ControlOrder));
Assert.NotEqual(OrderType.Stop, pet.ControlOrder);
}
}
[Fact]
public void IssueOrder_RevealsTheIssuer_NeverTheMaster()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend);
master.Hidden = true;
friend.Hidden = true;
pet.IssueOrder(OrderType.Stay, friend);
Assert.False(friend.Hidden);
Assert.True(master.Hidden);
}
[Fact]
public void SystemIssuedOrder_RevealsNobody()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
master.Hidden = true;
pet.ControlOrder = OrderType.Follow; // raw assignment = system-issued
Assert.True(master.Hidden);
}
[Fact]
public void EndPickTarget_Attack_SetsCombatantAndFocus()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var victim = SpawnPlayer(new Point3D(1003, 1000, 0));
pet.AIObject.EndPickTarget(master, victim, OrderType.Attack);
Assert.Equal(OrderType.Attack, pet.ControlOrder);
Assert.Same(victim, pet.ControlTarget);
Assert.Same(victim, pet.Combatant);
Assert.Same(victim, pet.FocusMob);
Assert.True(pet.Warmode);
Assert.Equal(1, pet.CombatantSets); // the Issue phase is the only writer
pet.AIObject.Obey(); // the tick does not rewrite it
Assert.Equal(1, pet.CombatantSets);
}
[Fact]
public void ReIssuedAttack_OnTheSameTarget_DoesNotRewriteCombatantOrFlapWarmode()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var victim = SpawnPlayer(new Point3D(1003, 1000, 0));
pet.IssueOrder(OrderType.Attack, master, victim);
Assert.Equal(1, pet.CombatantSets);
Assert.True(pet.Warmode);
// Dropping Warmode would null Combatant and make the re-issue replay DoHarmful.
pet.IssueOrder(OrderType.Attack, master, victim);
Assert.Equal(1, pet.CombatantSets);
Assert.True(pet.Warmode);
Assert.Same(victim, pet.Combatant);
Assert.Same(victim, pet.FocusMob);
}
[Fact]
public void OrderedAttack_ReassertsTheCommandedTarget_AfterAnAggressorStealsCombatant()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var victim = SpawnPlayer(new Point3D(1003, 1000, 0));
var other = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.IssueOrder(OrderType.Attack, master, victim);
// what OnAggressiveAction does
pet.Combatant = other;
Assert.Same(other, pet.Combatant);
pet.AIObject.Obey(); // the tick puts the kill order back on the commanded target
Assert.Same(victim, pet.Combatant);
Assert.Equal(OrderType.Attack, pet.ControlOrder);
}
[Fact]
public void Rename_WhileFollowing_KeepsFollowingTheMaster()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.IssueOrder(OrderType.Follow, master, master);
pet.IssueOrder(OrderType.Rename, master); // the menu passes no target
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.Same(master, pet.ControlTarget); // restored, not null
pet.AIObject.Obey();
Assert.Equal(OrderType.Follow, pet.ControlOrder); // no "no one to follow" -> None
}
[Fact]
public void TransferRefused_ResumesFollowingTheMaster_NotTheRecipient()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.IssueOrder(OrderType.Follow, master, master);
var recipient = SpawnPlayer(new Point3D(1002, 1000, 0)); // no NetState -> the transfer is refused
pet.IssueOrder(OrderType.Transfer, master, recipient);
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.Same(master, pet.ControlTarget);
Assert.True(pet.Controlled);
Assert.Same(master, pet.ControlMaster);
}
[Fact]
public void SameOrderTwice_ReRunsIssue()
{
var postA = new Point3D(1005, 1005, 0);
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), postA);
pet.ControlOrder = OrderType.Stay; // Home = A
pet.MoveToWorld(new Point3D(1050, 1050, 0), pet.Map);
pet.ControlOrder = OrderType.Stay; // reissued: re-anchor
Assert.Equal(pet.Location, pet.Home);
}
[Fact]
public void LoyaltyRelease_AndManualRelease_ProduceTheSameEndState()
{
var (masterA, petA) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var (masterB, petB) = Spawn(new Point3D(1100, 1100, 0), new Point3D(1101, 1100, 0));
petA.Name = "Rex";
petB.Name = "Rex";
petA.IsBonded = true;
petB.IsBonded = true;
petA.IssueOrder(OrderType.Release, masterA); // player
petB.ControlOrder = OrderType.Release; // what the loyalty drain does
PetTestStub[] pets = [petA, petB];
for (var i = 0; i < pets.Length; i++)
{
var pet = pets[i];
Assert.False(pet.Controlled);
Assert.Null(pet.ControlMaster);
Assert.False(pet.IsBonded);
Assert.Null(pet.Name);
Assert.Equal(OrderType.None, pet.ControlOrder);
Assert.True(pet.PendingDeleteTimer?.Running);
Assert.Equal(pet.Location, pet.Home);
}
Assert.Equal(masterA.Followers, masterB.Followers);
}
[Fact]
public void SummonedPet_Released_IsKilledNotReleased()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Summoned = true;
pet.SummonMaster = master;
pet.ControlOrder = OrderType.Release;
Assert.True(pet.Deleted || !pet.Alive);
}
[Fact]
public void Stop_WithNoStandingOrder_IdlesAnchoredWhereItStands()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
Assert.Equal(OrderType.Come, pet.ControlOrder); // fresh tame: no standing order, Home = Zero
Assert.Equal(Point3D.Zero, pet.Home);
pet.ControlOrder = OrderType.Stop; // what a vendor does after SetControlMaster(buyer)
Assert.Equal(OrderType.None, pet.ControlOrder);
Assert.Equal(pet.Location, pet.Home); // anchored: no unbounded wander
}
[Fact]
public void Release_ClearsFriendsAndTheStandingOrder()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend);
pet.ControlOrder = OrderType.Guard; // persistent = Guard
pet.IssueOrder(OrderType.Release, master);
Assert.False(pet.IsPetFriend(friend));
Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder);
}
[Fact]
public void PetDeath_IssuesFollowMaster_WithoutRevealingAnyone()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.IsBonded = true;
pet.ControlOrder = OrderType.Stay;
pet.ControlTarget = null;
master.Hidden = true;
pet.Kill(); // bonded pet death -> IsDeadPet, follows the master
Assert.True(pet.IsDeadPet);
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.Same(master, pet.ControlTarget);
Assert.True(master.Hidden);
Assert.False(pet.Warmode);
}
[Fact]
public void ObeyOnALegacyTransientOrder_FallsBackToPersistent()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.ControlOrder = OrderType.Follow; // persistent = Follow
// a pre-refactor save resting at Rename
var field = typeof(BaseCreature).GetField("_controlOrder", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
field!.SetValue(pet, OrderType.Rename);
Assert.Equal(OrderType.Rename, pet.ControlOrder);
pet.AIObject.Obey();
Assert.Equal(OrderType.Follow, pet.ControlOrder);
}
[Fact]
public void SpeechCommand_FromAFriend_RevealsTheFriend_NotTheMaster()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend);
master.Hidden = true;
friend.Hidden = true;
// "all stay" keyword 0x170
pet.AIObject.OnSpeech(new SpeechEventArgs(friend, "all stay", MessageType.Regular, 0x3B2, [0x170]));
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.False(friend.Hidden);
Assert.True(master.Hidden);
}
[Fact]
public void ContextMenuCommand_FromAFriend_RevealsTheFriend_NotTheMaster()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend);
master.Hidden = true;
friend.Hidden = true;
new InternalEntry(3006114, 14, OrderType.Stay, true).OnClick(friend, pet); // Command: Stay
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.False(friend.Hidden);
Assert.True(master.Hidden);
}
[Fact]
public void ContextMenuCommand_FromAFriend_RefusesNonFriendOrders()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend);
pet.ControlOrder = OrderType.Follow;
new InternalEntry(3006107, 14, OrderType.Guard, true).OnClick(friend, pet); // Command: Guard
Assert.Equal(OrderType.Follow, pet.ControlOrder);
}
[Fact]
public void ContextMenuRename_LeavesThePetOnARestableOrder()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.ControlOrder = OrderType.Follow;
new InternalEntry(3006098, 14, OrderType.Rename, true).OnClick(master, pet); // Rename
Assert.Equal(OrderType.Follow, pet.ControlOrder);
}
// Come with no standing order (fresh tame or post-load) must settle into one.
[Fact]
public void RestingCome_WithNoStandingOrder_SettlesIntoStayBesideTheMaster()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
Assert.Equal(OrderType.Come, pet.ControlOrder);
Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder);
pet.AIObject.Obey(); // within 2 tiles -> Stay
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
Assert.Equal(pet.Location, pet.Home);
}
// Load never runs the Issue phase. Home (field 12) is read before ControlOrder (18); with an AI
// present, a setter-routed load would re-anchor Home to the restored Location.
[Fact]
public void ControlOrder_RoundTrips_AndLoadDoesNotRunIssue()
{
var post = new Point3D(1001, 1000, 0);
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), post);
pet.ControlOrder = OrderType.Stay; // Home = post
pet.MoveToWorld(new Point3D(1020, 1000, 0), pet.Map); // displaced: Home != Location
Assert.Equal(post, pet.Home);
var writer = new BufferWriter(true);
pet.Serialize(writer);
var buffer = new byte[writer.Position];
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
var copy = new PetTestStub(World.NewMobile);
_created.Add(copy);
copy.ChangeAIType(AIType.AI_Animal); // the Issue gate is ai != null
copy.Deserialize(new BufferReader(buffer));
Assert.Equal(OrderType.Stay, copy.ControlOrder);
Assert.Equal(new Point3D(1020, 1000, 0), copy.Location);
Assert.Equal(post, copy.Home); // not re-anchored
}
[Fact]
public void SpeechCommand_WithoutThePetsName_IsIgnored()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Name = "Rex";
pet.ControlOrder = OrderType.Stay;
// bare "come" (keyword 0x155) with no name: not for this pet
pet.AIObject.OnSpeech(new SpeechEventArgs(master, "come", MessageType.Regular, 0x3B2, [0x155]));
Assert.Equal(OrderType.Stay, pet.ControlOrder);
pet.AIObject.OnSpeech(new SpeechEventArgs(master, "Rex come", MessageType.Regular, 0x3B2, [0x155]));
Assert.Equal(OrderType.Come, pet.ControlOrder);
}
[Fact]
public void AllCommand_IssuesOnce()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Name = "Rex";
pet.ControlOrder = OrderType.Follow;
var post = pet.Location;
// The client emits both 0x170 ("all stay") and 0x16F ("*stay") for "all stay".
pet.AIObject.OnSpeech(new SpeechEventArgs(master, "all stay", MessageType.Regular, 0x3B2, [0x170, 0x16F]));
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.Equal(post, pet.Home);
// The named keyword alone must be ignored: the speech starts with "all", not the name.
pet.MoveToWorld(new Point3D(1010, 1010, 0), pet.Map);
pet.AIObject.OnSpeech(new SpeechEventArgs(master, "all stay", MessageType.Regular, 0x3B2, [0x16F]));
Assert.Equal(post, pet.Home);
}
[Fact]
public void SpeechCommand_FromAFriend_CannotComeGuardOrDrop()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Name = "Rex";
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend);
pet.ControlOrder = OrderType.Stay;
var post = pet.Home;
pet.AIObject.OnSpeech(new SpeechEventArgs(friend, "Rex come", MessageType.Regular, 0x3B2, [0x155]));
Assert.Equal(OrderType.Stay, pet.ControlOrder);
pet.AIObject.OnSpeech(new SpeechEventArgs(friend, "Rex guard", MessageType.Regular, 0x3B2, [0x15C]));
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
pet.IsBonded = true; // CanDrop
pet.AIObject.OnSpeech(new SpeechEventArgs(friend, "Rex drop", MessageType.Regular, 0x3B2, [0x156]));
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.Equal(post, pet.Home); // never re-issued
// The friend can still Stay/Follow/Stop.
pet.AIObject.OnSpeech(new SpeechEventArgs(friend, "Rex follow me", MessageType.Regular, 0x3B2, [0x163]));
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.Same(friend, pet.ControlTarget);
}
[Fact]
public void GMObey_TakesControlOfACommandablePet()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Name = "Rex";
var gm = SpawnPlayer(new Point3D(1002, 1000, 0));
gm.AccessLevel = AccessLevel.GameMaster;
pet.AIObject.OnSpeech(new SpeechEventArgs(gm, "Rex obey", MessageType.Regular, 0x3B2, []));
Assert.Same(gm, pet.ControlMaster);
}
[Fact]
public void GMAllObey_DoesNotTakeControlledPets()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Name = "Rex";
var gm = SpawnPlayer(new Point3D(1002, 1000, 0));
gm.AccessLevel = AccessLevel.GameMaster;
// The mass form is for wild creatures; a controlled pet must be named.
pet.AIObject.OnSpeech(new SpeechEventArgs(gm, "all obey", MessageType.Regular, 0x3B2, []));
Assert.Same(master, pet.ControlMaster);
pet.AIObject.OnSpeech(new SpeechEventArgs(gm, "Rex obey", MessageType.Regular, 0x3B2, []));
Assert.Same(gm, pet.ControlMaster);
}
// Release is relinquishing control, not exerting it: no roll, so no loyalty either way.
[Fact]
public void MenuRelease_TouchesNoLoyalty()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Loyalty = 50;
new InternalEntry(3006118, 14, OrderType.Release, true).OnClick(master, pet); // Release
Assert.Equal(50, pet.Loyalty); // no roll: neither the +1 for passing nor the -3 for failing
}
[Fact]
public void SpeechRelease_TouchesNoLoyalty()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Name = "Rex";
pet.Loyalty = 50;
pet.AIObject.OnSpeech(new SpeechEventArgs(master, "Rex release", MessageType.Regular, 0x3B2, [0x16D]));
Assert.Equal(50, pet.Loyalty);
}
// A creature nobody can command is not released either.
[Fact]
public void MenuRelease_OnAnUncontrollablePet_IsRefusedWithoutCost()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.MinTameSkill = 120.0; // control chance at or below zero
master.Skills.AnimalTaming.Base = 0;
master.Skills.AnimalLore.Base = 0;
pet.Loyalty = 50;
new InternalEntry(3006118, 14, OrderType.Release, true).OnClick(master, pet);
Assert.Equal(50, pet.Loyalty); // refused, but never punished
Assert.True(pet.Controlled);
}
}