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 _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); } }