ModernUO/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs
Kamron Batman f606225f47
fix: pets freeze, forget their orders, obey the wrong players, and fight when told not to (#2614)
## Summary

Pet orders lived in two files with nothing enforcing which phase owned what: `PetOrderHandlers.cs` ran one-shot handlers inside the `ControlOrder` setter and `PetOrders.cs` ran `DoOrderXxx` every AI tick from `Obey`. Friend/Unfriend refusals repeated their message every tick, Rename froze the pet, Drop on a dead pet never ended, the loyalty drain bypassed the release handler, and the command issuer leaked through a public field that only some handlers cleared (#2613 fixed the Release casualty of that split; this finishes the job).

Every order now lives in one place, `PetOrders.cs`, with two named phases:

- **Issue** — `BaseAI.IssueOrder(order, previous, issuer, resuming, interruptedTarget)` runs once, synchronously, from the new `BaseCreature.SetControlOrder` funnel. It may only set state and emit (message, sound, reveal) and returns the order to rest in. The funnel loops to a fixed point, so transient orders (Drop, Friend, Unfriend, Transfer, Release, Rename, Stop, Patrol) resolve before the setter returns and can never rest.
- **Tick** — `DoOrderXxx` runs from `Obey` for the six restable orders only (None, Come, Guard, Attack, Stay, Follow). Anything else that arrives there came from an old save and falls back to the standing order.

The issuer is a parameter: `BaseCreature.IssueOrder(order, issuer, target)` is the entry for player commands (speech, context menu, targeting), a raw `ControlOrder = x` assignment is a system-issued order, and nothing has to remember to clear anything. A resumed Follow restores the mobile the standing Follow was following, never a transient's target.

Because the funnel is synchronous it also carries the order being interrupted, so an administrative command can hand control back to what the pet was doing without storing anything per creature.

## Era behaviour, with sources

Two publishes govern most of the questions here, and the inherited code matched neither exactly.

[**Publish 16**](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2002-2/publish-16-part-2-4-23rd-july/) (23 July 2002) — *"The 'stop' command will stop a pet from guarding, following, and attacking."* Stop cancels the current attack and leaves the pet idle but still reactive. That is what this branch does whenever the stand-down policy below is off, in every era. (The same publish's *"Friends will only be able to issue movement commands to pets"* is the rule already enforced by `IsFriendOrder`.)

[**Publish 51**](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2008-2/publish-51-26th-march/) (26 March 2008) lists it per command:

> Follow: The pet should follow. It will not attack anything, even if it is attacked.
>
> Come: The pet should come. It will not attack anything, even if it is attacked.
>
> Stay: The pet will stay where it is currently, and will not attack anything, even if it is attacked.
>
> Stop: The pet will stop attacking. It will not attack anything, even if it is attacked, and may wander.
>
> Guard: The pet should guard as it does currently.
>
> Kill/Attack: The pet will attack its target as it does currently.

The inherited rule covered Follow and Stay only, so a pet told to come fought back, and it could not cover Stop at all: Stop resolves to None rather than resting, and None is exactly the state the publish describes. `BaseAI.IsStandDownOrder` now names the set — Follow, Come, Stay, None — and both halves of `AggressiveAction` read it.

## Configuration

`taming.petsStandDownOnCommand` (default `Core.ML`) controls the Publish 51 behaviour. The publish has no step of its own on the expansion ladder — it lands between ML and SA, and Kingdom Reborn was a client rather than an expansion — so it keeps riding ML as before, and the setting carries the rest: the behaviour is popular well outside its era, so a shard on AOS that wants it sets the key, and one that does not clears it. `BaseCreature.StandsDownOnCommand` is virtual for a creature that should differ.

## Bugs fixed along the way

- Friend/Unfriend refusal spam (every tick until the next command); Rename freezing the pet; Drop on a dead or non-`CanDrop` pet freezing the pet.
- Loyalty-zero release skipping the name clear and the summoned kill; a released pet keeping its `Friends` list and its previous owner's standing order.
- A transferred pet still answering to the previous owner's friends; transfer playing the idle sound twice.
- `BaseTalisman` summons issued `Friend` with no target (*looks confused* forever, or young-player spam); they follow their owner.
- Speech: single-pet commands lost their name gate after #2232 (a bare "come" moved every pet in range; "all stay" issued Stay twice); the speech cases passed a hardcoded `isOwner: true`, so a pet friend could say "`<name>` drop" and dump the pack, or issue Come/Guard.
- GM "`<name>` obey" was unreachable for a controlled pet; context-menu Release and speech Release disagreed about the control roll (resolved by removing it from both, below).
- Login derived the standing order from proximity even for a pet saved on Stay (zeroing its post), and only recorded the derived order without issuing it, so a pet saved mid-transient idled after a restart.
- `Friends` mutations never marked the creature dirty for delta saves.
- A resumed standing Follow was left without a target and cancelled itself to idle on the next think — the same defect as #2616, fixed here by `IssueFollow(resuming)` restoring the remembered target.

## Behaviour changes a shard maintainer will notice

- Every player command reveals its issuer, including context-menu commands from a hidden owner. This restores the blanket reveal (RunUO reveals in every order arm) minus its bug: it revealed the **control master**, so a friend's command popped the owner wherever they stood. Speech already reveals through `Mobile.OnSaid`, so the practical change is the context menu, target picks and the release gump.
- Resumed/chained orders are silent (no idle sound when falling back after Drop, Stop, a refused Friend, etc.).
- **Administrative commands no longer call the pet off.** Drop, Friend, Unfriend and Rename keep the pet's combat posture and hand control back to the order they interrupted, target and all; the standing order is the fallback only when the interrupted order cannot resume (a transient, or an attack whose target died, left or hid). Resuming an attack does not repeat its aggression or replay its bark.
- **Friend and Unfriend no longer rewrite the standing order.** Previously a success pointed the pet at the new friend and made Follow its standing order, so a pet left on Stay silently became a Follow with its anchor cleared. Friending grants a permission and nothing else; the friend has movement commands and can ask the pet to follow.
- **Releasing a pet no longer rolls the control chance**, on either path. A refused roll cost 3 loyalty, and loyalty reaching zero releases the pet anyway, so refusing only converted a deliberate release into an involuntary one minutes later. Both paths gate on `CanBeControlledBy` instead: if you can command it, you can dismiss it.
- **The pet distraction roll is gone.** A pet on Follow had a 10% chance per damage callback of dropping the order and attacking whoever hit it, issued without consulting anything, so it overrode the stand-down policy a few hits after the aggression path had correctly ignored it. Its era gate was guesswork by its own comment's admission and no publish describes it; `CanBeDistracted`, `CheckDistracted`, both call sites and the `Golem` override are deleted. Pre-ML shards lose the mechanic.
- **A pet's follow pace moved off the think clock.** The AOS sprint wrote a bespoke `CurrentSpeed = 0.1`, which fused both clocks — discarding any configured `ActiveMoveSpeed`/`PassiveMoveSpeed` — and pinned a following pet's AI at 10 Hz even while standing still. `BaseCreature.FollowMoveSpeed` (virtual, AOS 0.1) now caps the resolved step delay while the pet is closing on its master, the same way herding does: nothing stored, and a creature configured faster keeps its own pace.
- Transfer with an invalid target is a refusal (resumes) instead of forcing Stay; the transfer combat gate rests on the aggressor lists and `NextCombatTime`.
- Stop with no standing order anchors the idle where the pet stands (a vendor-bought pet no longer wanders off unbounded).
- The old "master must be alive" bails in the handlers are gone; stand-down and sounds run for orphaned pets too. RunUO gates only on the master being null or deleted, and a living friend commanding a dead owner's pet could not previously call it out of a fight.
- Login: a pet at None near its master is issued Follow silently; a saved Stay/Follow/Guard is adopted as is.
- GM "all obey" only reaches wild creatures; a controlled pet must be named.
- Death still issues Follow with the idle sound, as RunUO did.

## Tests

`PetOrderTests` grew from 16 to 63, plus 13 in a new `PetRetaliationTests` for the Publish 51 matrix and 13 in `PetPacingTests` for the clocks. Together they cover order resolution, reveal on every entry path, release parity (player vs drain, summoned), transfer/friend refusals and successes, stand-down and war-mode invariants, the interrupted-order resume, speech gating and permissions, GM obey, login derivation, the load probe, and the retaliation matrix across eras and both damage callbacks. Whole project green: 845 `UOContent.Tests`, 869 `Server.Tests`.
2026-09-10 19:28:13 -07:00

1043 lines
38 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 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);
}
}