ModernUO/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs
Kamron Batman 00134cac33
test: fix shared state, CI-vacuous tests and harness duplication (#2722)
Follow-up to #2721. It fixes how the remaining tests behave and changes no production code.

## CI
- Both `dotnet test` steps pass `--filter "Category!=Manual"`. Before this, Manual-tagged tests ran on CI anyway.
- The one surviving movement-throttle latency test is the only coverage of burst forgiveness over a full, wrapped history. It loses its Manual tag, so it runs on CI.

## Tests that passed on CI without testing anything
- **Corpse and throwing tests.** They relied on tiledata for the weapon `Layer`, so on CI the equip silently failed and they asserted against empty lists. They now set `Layer` explicitly and assert the equip took effect. `TestThrown` also pins its own strength and range values, so a shard retuning SoulGlaive no longer breaks them.
- **Healthbar poison rows.** Server.Tests registers no poisons, so every row tested `null`. They now use real poison levels.
- **Account packet tests.** The mock accounts reported `Length` 0, so the character-list tests only ever encoded empty slots. They now encode the real characters.
- **Pathing and AI tests that only mean something on real Trammel/Felucca terrain** are now guarded with `TileDataRequirement.SkipIfMissing()`: Britain desk and clutter scenes, house footprints (no MultiData on CI) and fingerprints. Tests that are valid on flat blank terrain stay unguarded. (A synthetic tiledata fixture to run these on CI is a planned follow-up.)
- **Assertions that couldn't fail now check their claim.** Affected tests include the `DefaultWalker` cache use, cache on/off parity requiring a path, the recorder capability flags, the idle-pet gate, the monthly DST recurrence, the AdvancedSearch `Record.Exception` wrappers, `CanFitItem` with no branch-guarded assert, the Firewall callback, and `Import_NoMap`. The Geometry Parse asserts also now use the same input as their TryParse.

## Shared state
Every test that changes process-wide state now restores it, even when an assert fails:
- **The clock.** A new `TimerWheelScope` saves and restores `Core._tickCount`, `Core._now` and the timer wheel. `SimulatedClockScope` covers decay tests.
- **StepCache settings and caches**, via `StepCacheStateScope`.
- **Network state.** The ManualAllowlist, LoginAllowlist, firewall and BanChannel reporters are restored. Global packet handlers are now registered per test and removed afterwards, so they no longer stay registered for later tests.
- **Data and config tables.** `ArchiveJournal` statics, `TileData.ItemTable`, `ObjectPropertyList.Enabled`, `Core.Expansion` and the localization table are restored. One `MobileStatusVersion` restore was resetting a different expansion than the one the test changed.
- **Entities.** Tests with fixed serials now delete what they create. Fixed mobile serials moved to `World.NewMobile`, because deleting a hard-coded serial could unregister a live entity.
- **Collection race.** `SpiralScanTests` no longer clears the spawn cache from a separate collection that could run in parallel with the cache-manager tests.
- **Shared statics.** Classes reading shared static allowlists joined the sequential collection.

## Duplication
New shared helpers replace copy-pasted harness code:
- **Server.Tests:** `PacketAssert.Sends`, used by about 130 packet tests; `TestClientHarness`, `PersistencePipelineScope`, `NetTestHelpers`, `EntityCleanup` and one `TestMulti`.
- **UOContent.Tests:** `TimerWheelScope`, which also holds one wraparound-safe `RunFor`/`RunUntil`; `AiTestStubs`, which merges six walker copies and five identical creature stubs; `MobileTestHarness`, `CommandTestHarness` and `TempDirectory`.

Dead oracle packet classes are removed.

## Cost
| Test | Before | After |
|---|---|---|
| `VirtualSerialTests` | 1.57s | 2ms (starts near the wrap via reflection instead of drawing ~17.9M serials) |
| Event scheduler recurrence loop | 473ms | 1ms |
| CrowdSec drain | 517ms | 16ms (event-driven instead of a fixed 500ms wait) |
| AuthId tests | 248ms | 99ms (SHA2 except the two GameLogin tests) |
| Container cap tests | 5,000 items | 3,449 / 3,277 (one past each protocol cap) |
| Bulk removal | 100k children | 20k children |
| OPL hash distribution | 60k builds | 10k builds |

## Shard-tuning decoupling
- AI assertions read `ReacquireDelay` and `FollowMoveSpeed` instead of hard-coding production tuning.
- The guard friendly-fire test uses an `AI_Mage` stub instead of WhiteWyrm.
- Argon2 assertions pin the `argon2id` variant and `v=19` but read the cost parameters from the configured hasher.

## Tests
| Run | Server.Tests | UOContent.Tests |
|---|---|---|
| With client files | 812 pass | 1,123 pass |
| Without client files (CI simulation) | 800 pass, 12 skip | 1,021 pass, 102 skip |

- The UOContent fixture reads `MODERNUO_TEST_DATA_DIR`; Server.Tests reads `MODERNUO_CLIENT_PATH`.
- The 15 new UOContent skips are the map-dependent guards above.
- Total run time with client files is about 5s, down from about 8s.
2026-10-18 20:11:33 -07:00

980 lines
36 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
}
// A step may be blocked by whatever terrain (1000, 1000) holds, but DoMoveImpl sets
// Mobile.Direction before attempting the move, so an *attempted* wander is observable via
// Direction either way. 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;
var start = pet.Location;
var ai = pet.AIObject;
for (var i = 0; i < 40; i++)
{
// Reopen the move clock every pass, so CheckIdle is the only gate left standing.
ai.NextMove = Core.TickCount;
Assert.True(ai.CanMoveNow(out _));
ai.DoOrderNone();
}
Assert.Equal(Direction.North, pet.Direction); // gated by CheckIdle -> never attempts a step
Assert.Equal(start, pet.Location);
}
[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 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);
}
// 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.Master = 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 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.
// A creature nobody can command is not released either.
}