diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs index db5225359..6765b07f9 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs @@ -45,8 +45,9 @@ public class GuardFollowTests // Without a move intent, guard-following only steps on the think grid. Assert.True(hasIntent, "guard-following must register a move intent"); - // AOS return sprint on both clocks; the per-step speed flip must not undo it. - Assert.Equal(0.1, currentSpeed); + // The return is paced by FollowMoveSpeed; the think clock stays on the active value + // and the per-step speed flip must not undo either. + Assert.Equal(0.2, currentSpeed); Assert.Equal(0.1, currentMoveSpeed); } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs index fbc6a2f81..91acee5d9 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs @@ -252,8 +252,8 @@ public class PetOrderTests : IDisposable var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); var followers = master.Followers; - // What the loyalty drain calls when loyalty reaches zero. - pet.AIObject.DoOrderRelease(); + // What the loyalty drain assigns when loyalty reaches zero. + pet.ControlOrder = OrderType.Release; Assert.False(pet.Controlled); Assert.Null(pet.ControlMaster); @@ -270,7 +270,7 @@ public class PetOrderTests : IDisposable pet.Home = new Point3D(800, 800, 0); // simulate a stale anchor pet.Spawner = null; - pet.AIObject.DoOrderRelease(); + pet.ControlOrder = OrderType.Release; Assert.Equal(loc, pet.Home); // released where it stands, not the stale point } @@ -296,6 +296,67 @@ public class PetOrderTests : IDisposable 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() { @@ -318,4 +379,665 @@ public class PetOrderTests : IDisposable 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); + } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs index 217049b3d..12d978e6d 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs @@ -54,9 +54,126 @@ public class PetPacingTests : IDisposable Assert.Equal(0.2, pet.CurrentSpeed); } - // AOS: following the master sprints at a bespoke 0.1 on both clocks. + // The follow pace caps the step delay and leaves the think clock on the active value. [Fact] - public void FollowMaster_ObeySprints() + public void FollowMaster_PacesStepsWithoutInflatingTheThinkClock() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; // fixture era is EJ + + Assert.Equal(0.2, pet.CurrentSpeed); // active think, not the follow pace + Assert.Equal(0.1, pet.CurrentMoveSpeed); // capped at the follow pace + } + + // A creature configured faster than the follow pace keeps its own. + [Fact] + public void FollowMaster_KeepsAFasterConfiguredPace() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.05, 0.9); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + + Assert.Equal(0.05, pet.CurrentMoveSpeed); + } + + // The move-clock override survives the order: it is capped while following, not overwritten. + [Fact] + public void FollowMaster_LeavesTheConfiguredMoveClockAlone() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + pet.AIObject.Obey(); + pet.ControlOrder = OrderType.Stay; + + Assert.Equal(0.3, pet.ActiveMoveSpeed); + Assert.Equal(0.9, pet.PassiveMoveSpeed); + Assert.Equal(0.9, pet.CurrentMoveSpeed); // resting on its own passive pace again + } + + // Pre-AOS pets follow at their own pace; nothing caps them. + [Fact] + public void FollowMaster_PreAOS_KeepsItsOwnPace() + { + var previous = Core.Expansion; + + try + { + Core.Expansion = Expansion.UOR; + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + + Assert.Equal(0.2, pet.CurrentSpeed); + Assert.Equal(0.3, pet.CurrentMoveSpeed); + } + finally + { + Core.Expansion = previous; + } + } + + private sealed class SprintingPet : PetTestStub + { + public override double FollowMoveSpeed => 0.125; + } + + // A shard paces follows in any era by overriding the property, not by patching the AI. + [Fact] + public void FollowMoveSpeedOverride_PacesFollowsInAnyEra() + { + var previous = Core.Expansion; + + try + { + Core.Expansion = Expansion.UOR; + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + _created.Add(master); + + var pet = new SprintingPet(); + pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); + pet.SetControlMaster(master); + _created.Add(pet); + pet.SetMoveSpeed(0.3, 0.9); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + + Assert.Equal(0.125, pet.CurrentMoveSpeed); + } + finally + { + Core.Expansion = previous; + } + } + + // A guarding pet outside guard range closes at the follow pace, thinking on its active clock. + [Fact] + public void GuardReturn_PacesStepsWithoutInflatingTheThinkClock() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1006, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + + pet.ControlOrder = OrderType.Guard; + + Assert.Equal(0.2, pet.CurrentSpeed); + Assert.Equal(0.1, pet.CurrentMoveSpeed); + } + + // Obeying the follow order must not write the pace into either clock. + [Fact] + public void FollowMaster_ObeyKeepsTheThinkClockActive() { var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); pet.SetMoveSpeed(0.3, 0.9); @@ -66,7 +183,7 @@ public class PetPacingTests : IDisposable pet.ControlOrder = OrderType.Follow; // fixture era is EJ pet.AIObject.Obey(); - Assert.Equal(0.1, pet.CurrentSpeed); + Assert.Equal(0.2, pet.CurrentSpeed); Assert.Equal(0.1, pet.CurrentMoveSpeed); } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs new file mode 100644 index 000000000..00316a351 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Publish 51 (26 March 2008): a pet told to follow, come, stay or stop "will not attack +// anything, even if it is attacked". Guard and attack are unaffected. +[Collection("Sequential UOContent Tests")] +public class PetRetaliationTests : IDisposable +{ + private readonly List _created = new(); + + public void Dispose() + { + foreach (var m in _created) + { + m?.Delete(); + } + + _created.Clear(); + } + + private sealed class StandDownPet : PetTestStub + { + public override bool StandsDownOnCommand => true; + } + + private sealed class FightBackPet : PetTestStub + { + public override bool StandsDownOnCommand => false; + } + + private (PlayerMobile master, T pet) Spawn() where T : BaseCreature, new() + { + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + _created.Add(master); + + var pet = new T(); + pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); + pet.SetControlMaster(master); + pet.AIObject.AITimer?.Stop(); + _created.Add(pet); + + return (master, pet); + } + + private void Order(BaseCreature pet, Mobile master, OrderType order) + { + if (order == OrderType.None) // Publish 51's "stop": stops, may wander, will not attack + { + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + pet.ControlOrder = OrderType.Stop; + return; + } + + pet.ControlTarget = master; + pet.ControlOrder = order; + } + + private BaseCreature Attack(BaseCreature pet) + { + var attacker = new PetTestStub(); + attacker.MoveToWorld(new Point3D(1002, 1000, 0), pet.Map); + _created.Add(attacker); + pet.AIObject.AITimer?.Stop(); + + attacker.Combatant = pet; // a mob starts attacking the pet + return attacker; + } + + [Theory] + [InlineData(OrderType.Follow)] + [InlineData(OrderType.Come)] + [InlineData(OrderType.Stay)] + [InlineData(OrderType.None)] // stopped + public void StandDownOrder_IgnoresTheAttacker(OrderType order) + { + var (master, pet) = Spawn(); + Order(pet, master, order); + var resting = pet.ControlOrder; + + Attack(pet); + + Assert.Equal(resting, pet.ControlOrder); // never converts to Attack + Assert.Null(pet.Combatant); + Assert.False(pet.Warmode); + } + + [Theory] + [InlineData(OrderType.Follow)] + [InlineData(OrderType.Come)] + [InlineData(OrderType.Stay)] + [InlineData(OrderType.None)] + public void WithoutStandDown_TheSameOrdersRetaliate(OrderType order) + { + var (master, pet) = Spawn(); + Order(pet, master, order); + + var attacker = Attack(pet); + + Assert.Equal(OrderType.Attack, pet.ControlOrder); + Assert.Same(attacker, pet.Combatant); + } + + // No damage callback may put a stand-down pet back into combat behind the policy's back. + [Theory] + [InlineData(Expansion.AOS, false)] + [InlineData(Expansion.AOS, true)] + [InlineData(Expansion.ML, false)] + [InlineData(Expansion.ML, true)] + public void StandDownPet_StaysDown_ThroughRepeatedDamage(Expansion era, bool spellDamage) + { + var previous = Core.Expansion; + + try + { + Core.Expansion = era; + var (master, pet) = Spawn(); + Order(pet, master, OrderType.Follow); + var attacker = Attack(pet); + + Assert.Equal(OrderType.Follow, pet.ControlOrder); // the initial aggression stood down + + for (var i = 0; i < 500 && pet.ControlOrder == OrderType.Follow; i++) + { + if (spellDamage) + { + pet.OnDamagedBySpell(attacker, 1); + } + else + { + pet.OnDamage(1, attacker, false); + } + } + + Assert.Equal(OrderType.Follow, pet.ControlOrder); + Assert.Null(pet.Combatant); + } + finally + { + Core.Expansion = previous; + } + } + + // "Guard: the pet should guard as it does currently." + [Fact] + public void GuardingPet_StillFights_UnderStandDown() + { + var (master, pet) = Spawn(); + Order(pet, master, OrderType.Guard); + + var attacker = Attack(pet); + + Assert.Equal(OrderType.Guard, pet.ControlOrder); + Assert.Same(attacker, pet.Combatant); + Assert.True(pet.Warmode); + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs index a759cc024..d15228590 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs @@ -23,6 +23,23 @@ public class PetTestStub : BaseCreature passiveSpeed = 0.4; } + // Effective (value-changing, non-null) Combatant writes; a re-issue on the same target must not add one. + public int CombatantSets { get; private set; } + + public override Mobile Combatant + { + get => base.Combatant; + set + { + if (value != null && base.Combatant != value) + { + CombatantSets++; + } + + base.Combatant = value; + } + } + public override bool CheckIdle() => ForceIdle || base.CheckIdle(); public PetTestStub(Serial serial) : base(serial) diff --git a/Projects/UOContent/Gumps/ConfirmReleaseGump.cs b/Projects/UOContent/Gumps/ConfirmReleaseGump.cs index 1863881a1..aa3bdc4bc 100644 --- a/Projects/UOContent/Gumps/ConfirmReleaseGump.cs +++ b/Projects/UOContent/Gumps/ConfirmReleaseGump.cs @@ -42,7 +42,6 @@ public class ConfirmReleaseGump : StaticGump return; } - _pet.ControlTarget = null; - _pet.ControlOrder = OrderType.Release; + _pet.IssueOrder(OrderType.Release, _from); } } diff --git a/Projects/UOContent/Items/Talismans/BaseTalisman.cs b/Projects/UOContent/Items/Talismans/BaseTalisman.cs index f7b118df6..5f594b399 100644 --- a/Projects/UOContent/Items/Talismans/BaseTalisman.cs +++ b/Projects/UOContent/Items/Talismans/BaseTalisman.cs @@ -502,7 +502,7 @@ public partial class BaseTalisman : Item, IAosItem ); mob.Summoned = false; - mob.ControlOrder = OrderType.Friend; + mob.IssueOrder(OrderType.Follow, null, from); _creature = mob; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 8b2a16cf2..381958d68 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -612,6 +612,24 @@ public abstract partial class BaseAI Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null; + // Following its master, or guarding from outside guard range. FollowMoveSpeed caps the step + // delay while this holds. + public bool IsPacingToMaster() + { + if (!Mobile.Controlled || Mobile.Combatant != null) + { + return false; + } + + return Mobile.ControlOrder switch + { + OrderType.Follow => Mobile.ControlTarget == Mobile.ControlMaster, + OrderType.Guard => Mobile.ControlMaster?.Deleted == false && + (int)Mobile.GetDistanceToSqrt(Mobile.ControlMaster) > GuardRange, + _ => false + }; + } + // A pet executing a movement order outside combat; its order handler owns its speed. [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsObeyingMoveOrder() => diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 4b8753d10..e7b16be9c 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -26,6 +26,9 @@ namespace Server.Mobiles; public abstract partial class BaseAI { + // How far a guarding pet may drift from its master before it closes the gap. + internal const int GuardRange = 3; + // Last-known-position tracking: recorded while the combatant is in LOS; drives the // guard-time investigation and the instant re-engage. private const int GuardGraceDuration = 10_000; @@ -35,7 +38,6 @@ public abstract partial class BaseAI private ActionType _action; public long _nextDetectHidden; public DateTime _lastOrder = DateTime.MinValue; - public Mobile _commandIssuer; private Mobile _lkpTarget; private Point3D _lkpLocation; @@ -160,15 +162,7 @@ public abstract partial class BaseAI if (Mobile.CheckControlChance(from)) { - Mobile.ControlTarget = target; - Mobile.ControlOrder = order; - - if (order == OrderType.Attack) - { - Mobile.FocusMob = target; - Mobile.Combatant = target; - Action = ActionType.Combat; - } + Mobile.IssueOrder(order, from, target); } } @@ -188,7 +182,7 @@ public abstract partial class BaseAI return false; } - if (isFriend && order is not (OrderType.Follow or OrderType.Stay or OrderType.Stop)) + if (isFriend && !IsFriendOrder(order)) { return false; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/InternalEntry.cs b/Projects/UOContent/Mobiles/AI/BaseAI/InternalEntry.cs index 9bee2edff..d245f91b6 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/InternalEntry.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/InternalEntry.cs @@ -47,7 +47,7 @@ internal sealed class InternalEntry : ContextMenuEntry return from.CheckAlive() && bc != null && !bc.Deleted && bc.Controlled; } - private bool IsInvalidOrderForDeadPet(BaseCreature bc) => bc.IsDeadPet && _order is OrderType.Guard or OrderType.Attack or OrderType.Transfer or OrderType.Drop; + private bool IsInvalidOrderForDeadPet(BaseCreature bc) => bc.IsDeadPet && BaseAI.IsDeadPetOrder(_order); private static bool IsOwnerOrFriend(Mobile from, BaseCreature bc, out bool isFriend) { @@ -56,7 +56,7 @@ internal sealed class InternalEntry : ContextMenuEntry return isOwner || isFriend; } - private bool IsInvalidOrderForFriend(bool isFriend) => isFriend && _order is not (OrderType.Follow or OrderType.Stay or OrderType.Stop); + private bool IsInvalidOrderForFriend(bool isFriend) => isFriend && !BaseAI.IsFriendOrder(_order); private void HandleOrder(Mobile from, BaseCreature bc) { @@ -99,9 +99,16 @@ internal sealed class InternalEntry : ContextMenuEntry private void HandleReleaseOrder(Mobile from, BaseCreature bc) { + // No roll: a refused one would only drain loyalty toward the involuntary release the + // drain performs anyway. Whoever can command the creature may dismiss it. + if (!bc.CanBeControlledBy(from)) + { + return; + } + if (bc.Summoned) { - HandleDefaultOrder(from, bc); + bc.IssueOrder(OrderType.Release, from); return; } @@ -112,8 +119,7 @@ internal sealed class InternalEntry : ContextMenuEntry { if (bc.CheckControlChance(from)) { - bc.ControlTarget = null; - bc.ControlOrder = _order; + bc.IssueOrder(_order, from); } } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs index ce9af5bb7..7ce0994f6 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs @@ -48,16 +48,23 @@ public abstract partial class BaseAI } } - if (Mobile.Controlled && Mobile.Commandable) + // Staff first, so " obey" reaches a controlled pet. + if (e.Mobile.AccessLevel >= AccessLevel.GameMaster && HandleGMCommands(e)) { - AllOnSpeechPet(e); - NamedOnSpeechPet(e); return; } - if (e.Mobile.AccessLevel >= AccessLevel.GameMaster) + if (Mobile.Controlled && Mobile.Commandable) { - HandleGMCommands(e); + // Exactly one handler per utterance: named addresses this pet, "all" every pet in range. + if (WasNamed(e.Speech)) + { + NamedOnSpeechPet(e); + } + else + { + AllOnSpeechPet(e); + } } } @@ -158,7 +165,7 @@ public abstract partial class BaseAI { case 0x164: // all come { - HandleComeCommand(e.Mobile, true); + HandleComeCommand(e.Mobile, isOwner); break; } case 0x165: // all follow @@ -169,7 +176,7 @@ public abstract partial class BaseAI case 0x166: // all guard case 0x16B: // all guard me { - HandleGuardCommand(e.Mobile, true); + HandleGuardCommand(e.Mobile, isOwner); break; } case 0x167: // all stop @@ -180,7 +187,7 @@ public abstract partial class BaseAI case 0x168: // all kill case 0x169: // all attack { - HandleAttackCommand(e.Mobile, true); + HandleAttackCommand(e.Mobile, isOwner); break; } case 0x16C: // all follow me @@ -230,12 +237,12 @@ public abstract partial class BaseAI { case 0x155: // *come { - HandleComeCommand(e.Mobile, true); + HandleComeCommand(e.Mobile, isOwner); break; } case 0x156: // *drop { - HandleDropCommand(e.Mobile, true, e.Speech); + HandleDropCommand(e.Mobile, isOwner); break; } case 0x15A: // *follow @@ -245,18 +252,18 @@ public abstract partial class BaseAI } case 0x15B: // *friend { - HandleFriendCommand(e.Mobile, true, e.Speech); + HandleFriendCommand(e.Mobile, isOwner); break; } case 0x15C: // *guard { - HandleGuardCommand(e.Mobile, true); + HandleGuardCommand(e.Mobile, isOwner); break; } case 0x15D: // *kill case 0x15E: // *attack { - HandleAttackCommand(e.Mobile, true); + HandleAttackCommand(e.Mobile, isOwner); break; } case 0x161: // *stop @@ -271,12 +278,12 @@ public abstract partial class BaseAI } case 0x16D: // *release { - HandleReleaseCommand(e.Mobile, true, e.Speech); + HandleReleaseCommand(e.Mobile, isOwner); break; } case 0x16E: // *transfer { - HandleTransferCommand(e.Mobile, true, e.Speech); + HandleTransferCommand(e.Mobile, isOwner); break; } case 0x16F: // *stay @@ -330,9 +337,7 @@ public abstract partial class BaseAI { if (isOwner && Mobile.CheckControlChance(from)) { - _commandIssuer = from; - Mobile.ControlTarget = null; - Mobile.ControlOrder = OrderType.Come; + Mobile.IssueOrder(OrderType.Come, from); } } @@ -340,9 +345,7 @@ public abstract partial class BaseAI { if (isOwner && Mobile.CheckControlChance(from)) { - _commandIssuer = from; - Mobile.ControlTarget = null; - Mobile.ControlOrder = OrderType.Guard; + Mobile.IssueOrder(OrderType.Guard, from); } } @@ -350,9 +353,7 @@ public abstract partial class BaseAI { if (Mobile.CheckControlChance(from)) { - _commandIssuer = from; - Mobile.ControlTarget = target; - Mobile.ControlOrder = order; + Mobile.IssueOrder(order, from, target); } } @@ -360,25 +361,21 @@ public abstract partial class BaseAI { if (isOwner) { - _commandIssuer = from; BeginPickTarget(from, OrderType.Attack); } } - private void HandleDropCommand(Mobile from, bool isOwner, string speech) + private void HandleDropCommand(Mobile from, bool isOwner) { - if (isOwner && !Mobile.IsDeadPet && !Mobile.Summoned && WasNamed(speech) - && Mobile.CheckControlChance(from)) + if (isOwner && !Mobile.IsDeadPet && !Mobile.Summoned && Mobile.CheckControlChance(from)) { - _commandIssuer = from; - Mobile.ControlTarget = null; - Mobile.ControlOrder = OrderType.Drop; + Mobile.IssueOrder(OrderType.Drop, from); } } - private void HandleFriendCommand(Mobile from, bool isOwner, string speech) + private void HandleFriendCommand(Mobile from, bool isOwner) { - if (isOwner && WasNamed(speech) && Mobile.CheckControlChance(from)) + if (isOwner && Mobile.CheckControlChance(from)) { if (Mobile.Summoned || Mobile is GrizzledMare) { @@ -398,29 +395,31 @@ public abstract partial class BaseAI } } - private void HandleReleaseCommand(Mobile from, bool isOwner, string speech) + private void HandleReleaseCommand(Mobile from, bool isOwner) { if (!isOwner) { return; } - if (WasNamed(speech) && Mobile.CheckControlChance(from)) + // No control roll: see InternalEntry.HandleReleaseOrder. + if (!Mobile.CanBeControlledBy(from)) { - if (!Mobile.Summoned) - { - from.SendGump(new ConfirmReleaseGump(from, Mobile)); - } - else - { - Mobile.ControlOrder = OrderType.Release; - } + return; } + + if (Mobile.Summoned) + { + Mobile.IssueOrder(OrderType.Release, from); + return; + } + + from.SendGump(new ConfirmReleaseGump(from, Mobile)); } - private void HandleTransferCommand(Mobile from, bool isOwner, string speech) + private void HandleTransferCommand(Mobile from, bool isOwner) { - if (isOwner && !Mobile.IsDeadPet && WasNamed(speech) && Mobile.CheckControlChance(from)) + if (isOwner && !Mobile.IsDeadPet && Mobile.CheckControlChance(from)) { if (Mobile.Summoned || Mobile is GrizzledMare) { @@ -440,18 +439,23 @@ public abstract partial class BaseAI } } - private void HandleGMCommands(SpeechEventArgs e) + private bool HandleGMCommands(SpeechEventArgs e) { this.DebugSayFormatted($"Command is from GM: {e.Mobile.Name}, Target: {Mobile.ControlTarget?.Name ?? "None or Unknown"}"); - if (Mobile.FindMyName(e.Speech, true) && e.Speech.InsensitiveContains("obey")) + // "all obey" is for wild creatures; a controlled pet must be named. + if (!Mobile.FindMyName(e.Speech, !Mobile.Controlled) || !e.Speech.InsensitiveContains("obey")) { - Mobile.SetControlMaster(e.Mobile); - - if (Mobile.SummonMaster != null) - { - Mobile.SummonMaster = e.Mobile; - } + return false; } + + Mobile.SetControlMaster(e.Mobile); + + if (Mobile.SummonMaster != null) + { + Mobile.SummonMaster = e.Mobile; + } + + return true; } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs index 446b25b21..16ca9648a 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs @@ -25,9 +25,8 @@ public static class PetLoginHandler [OnEvent(nameof(PlayerMobile.PlayerLoginEvent))] public static void OnLogin(PlayerMobile pm) => DeriveFollowerOrders(pm); - // The persistent command is runtime-only and reset to None on load. When the master logs - // in we give each controlled pet that still has no standing command a sane one, inferred - // from proximity: near master -> Follow, otherwise Stay. + // PersistentOrder is runtime-only; ControlOrder and Home are saved. A pet saved mid-transient + // gets Follow near the master, else Stay. public static void DeriveFollowerOrders(PlayerMobile master) { if (master?.AllFollowers == null) @@ -37,14 +36,30 @@ public static class PetLoginHandler foreach (var follower in master.AllFollowers) { - if (follower is BaseCreature { Controlled: true, Deleted: false } bc - && bc.ControlMaster == master - && bc.AIObject is { } ai - && ai.PersistentOrder == OrderType.None) + if (follower is not BaseCreature { Controlled: true, Deleted: false } bc + || bc.ControlMaster != master + || bc.AIObject is not { } ai + || ai.PersistentOrder != OrderType.None) { - var near = bc.Map == master.Map && bc.GetDistanceToSqrt(master) <= FollowRange; - ai.SetPersistentOrder(near ? OrderType.Follow : OrderType.Stay); + continue; } + + var restored = bc.ControlOrder; + + if (restored is OrderType.Stay or OrderType.Follow or OrderType.Guard) + { + ai.RestorePersistentOrder(restored); + continue; + } + + var near = bc.Map == master.Map && bc.GetDistanceToSqrt(master) <= FollowRange; + var derived = near ? OrderType.Follow : OrderType.Stay; + + // ControlTarget first: SetPersistentOrder records it as the Follow target, and a + // mid-Attack save still holds the victim. + bc.ControlTarget = near ? master : null; + ai.SetPersistentOrder(derived); + bc.SetControlOrder(derived, null, true); } } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs deleted file mode 100644 index a132bc896..000000000 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ /dev/null @@ -1,280 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: PetOrderHandlers.cs * - * * - * This program is free software: you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation, either version 3 of the License, or * - * (at your option) any later version. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program. If not, see . * - ************************************************************************/ - -using System; - -namespace Server.Mobiles; - -public abstract partial class BaseAI -{ - public virtual void OnCurrentOrderChanged(OrderType previous) - { - if (Mobile.Deleted || Mobile.ControlMaster?.Deleted != false) - { - return; - } - - AITimer.Prod(); - - switch (Mobile.ControlOrder) - { - case OrderType.None: - { - HandleNoOrder(); - break; - } - case OrderType.Come: - { - Mobile.SetCurrentSpeedToActive(); - break; - } - case OrderType.Drop: - case OrderType.Friend: - case OrderType.Unfriend: - { - break; - } - case OrderType.Release: - { - HandleReleaseOrder(); - break; - } - case OrderType.Stop: - { - // Stop is resolved into another order; it never rests as the active order. - ResolveStop(previous); - return; - } - case OrderType.Transfer: - { - HandleTransferOrder(); - break; - } - case OrderType.Stay: - { - HandleStayOrder(); - break; - } - case OrderType.Guard: - { - HandleGuardOrder(); - break; - } - case OrderType.Attack: - { - HandleAttackOrder(); - break; - } - case OrderType.Follow: - { - HandleFollowOrder(); - break; - } - case OrderType.Rename: - { - HandleRenameOrder(); - break; - } - } - - // A freshly issued standing command becomes the persistent fallback and (re)anchors - // Home. Skipped while resuming a fallback so a resume never re-anchors. See - // ResumePersistentOrder. - if (!_resolvingOrder && Mobile.ControlOrder is OrderType.Stay or OrderType.Follow or OrderType.Guard) - { - SetPersistentOrder(Mobile.ControlOrder); - } - } - - // "Stop" cancels the active order, mapping to a resting order based on what the pet was - // doing: Attack/Come/etc. -> resume the persistent command; Follow/Guard -> cancel to idle - // (None) where it stands; Stay -> remain staying at its post. - private void ResolveStop(OrderType previous) - { - _commandIssuer?.RevealingAction(); - _commandIssuer = null; - Mobile.ControlTarget = null; - - switch (previous) - { - case OrderType.Stay: - { - _resolvingOrder = true; - Mobile.ControlOrder = OrderType.Stay; // remain staying; anchor untouched - _resolvingOrder = false; - break; - } - case OrderType.Follow: - case OrderType.Guard: - { - SetPersistentOrder(OrderType.None); // cancel standing order; anchor = current - _resolvingOrder = true; - Mobile.ControlOrder = OrderType.None; // idle - _resolvingOrder = false; - break; - } - default: // Attack / Come / Drop / None / etc. -> resume the standing order - { - ResumePersistentOrder(); - break; - } - } - } - - private void HandleNoOrder() - { - Mobile.ControlTarget = null; - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; - Mobile.SetCurrentSpeedToPassive(); - } - - private void HandleTransferOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - _commandIssuer?.RevealingAction(); - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; - Mobile.SetCurrentSpeedToPassive(); - Mobile.PlaySound(Mobile.GetIdleSound()); - _commandIssuer = null; - } - - private void HandleGuardOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - _commandIssuer?.RevealingAction(); - Mobile.FocusMob = null; - Mobile.Warmode = true; - Mobile.SetCurrentSpeedToActive(); - - // Resuming the persistent order must not replay the flourish. - if (!_resolvingOrder) - { - Mobile.PlaySound(Mobile.GetAttackSound()); - Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); - // ~1_NAME~ is now guarding you. - } - - _commandIssuer = null; - } - - private void HandleAttackOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - _commandIssuer?.RevealingAction(); - - if (Mobile.ControlTarget != null && - !Mobile.ControlTarget.Deleted && - Mobile.ControlTarget.Alive) - { - Mobile.FocusMob = Mobile.ControlTarget; - Mobile.Combatant = Mobile.ControlTarget; - } - else - { - Mobile.FocusMob = null; - Mobile.Combatant = null; - } - - Mobile.Warmode = true; - Mobile.SetCurrentSpeedToActive(); - Mobile.PlaySound(Mobile.GetAttackSound()); - _commandIssuer = null; - } - - private void HandleFollowOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - _commandIssuer?.RevealingAction(); - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; - Mobile.SetCurrentSpeedToActive(); - Mobile.PlaySound(Mobile.GetIdleSound()); - _commandIssuer = null; - } - - private void HandleStayOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - _commandIssuer?.RevealingAction(); - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; - Mobile.SetCurrentSpeedToPassive(); - Mobile.PlaySound(Mobile.GetIdleSound()); - _commandIssuer = null; - // Home (the stay anchor) is owned by SetPersistentOrder, not this handler. - } - - private void HandleReleaseOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - if (Mobile.Summoned) - { - Mobile.Kill(); - return; - } - - if (!string.IsNullOrEmpty(Mobile.Name)) - { - Mobile.Name = null; - } - - _commandIssuer?.RevealingAction(); - Mobile.PlaySound(Mobile.GetIdleSound()); - DoOrderRelease(); - _commandIssuer = null; - } - - public virtual void HandleRenameOrder() - { - if (Mobile.Summoned) - { - Mobile.ControlMaster?.SendMessage("You cannot rename a summoned creature."); - } - else - { - Mobile.ControlMaster?.SendMessage("Change name on pet health bar."); - } - } -} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 3282493b8..1e751e9ab 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -19,61 +19,580 @@ namespace Server.Mobiles; public abstract partial class BaseAI { - // The standing command a pet falls back to when a transient order (Attack/Come/Drop) - // completes: None, Stay, Follow, or Guard. Runtime-only (not serialized); reset to None - // on load and derived from master proximity on login. See PetLoginHandler. + // Runtime-only: None after a load until PetLoginHandler derives it. internal OrderType PersistentOrder { get; private set; } = OrderType.None; - // Guards anchor/persistent derivation while we resume a fallback order, so a resume - // never re-derives the persistent command or re-anchors Home. See OnCurrentOrderChanged. - private bool _resolvingOrder; + public static bool IsRestableOrder(OrderType order) => + order is OrderType.None or OrderType.Come or OrderType.Guard or OrderType.Attack or OrderType.Stay + or OrderType.Follow; - // Who a standing Follow follows: usually the master, but a pet friend can point it - // elsewhere ("all follow me"). Runtime-only, like PersistentOrder. - private Mobile _persistentFollowTarget; + public static bool IsFriendOrder(OrderType order) => + order is OrderType.Follow or OrderType.Stay or OrderType.Stop; + + // Everything else stands the pet down before it runs. + private static bool KeepsCombatPosture(OrderType order) => + order is OrderType.Attack or OrderType.Guard or OrderType.Drop or OrderType.Friend + or OrderType.Unfriend or OrderType.Rename; + + // Publish 51: told any of these, a pet "will not attack anything, even if it is attacked". + // Stop resolves to None, which is its resting form ("and may wander"). + public static bool IsStandDownOrder(OrderType order) => + order is OrderType.Follow or OrderType.Come or OrderType.Stay or OrderType.None; + + // Orders a dead bonded pet refuses. + public static bool IsDeadPetOrder(OrderType order) => + order is OrderType.Guard or OrderType.Attack or OrderType.Transfer or OrderType.Drop; + + // A targeted command overwrites ControlTarget; a resumed Follow restores it from here. + private Mobile _persistentTarget; // The controlled-pet wander anchor (Home) is a pure function of the persistent command. internal void SetPersistentOrder(OrderType order) { PersistentOrder = order; - _persistentFollowTarget = order == OrderType.Follow ? Mobile.ControlTarget ?? Mobile.ControlMaster : null; + _persistentTarget = order == OrderType.Follow ? Mobile.ControlTarget : null; Mobile.Home = order is OrderType.Follow or OrderType.Guard ? Point3D.Zero : Mobile.Location; } - // Resume the persistent command without re-deriving the persistent order or anchor. - private void ResumePersistentOrder() + // Adopt a saved standing order; Home and ControlTarget were saved with it. + internal void RestorePersistentOrder(OrderType order) { - // The ending order owns ControlTarget and leaves it cleared or pointing elsewhere; - // without a target DoOrderFollow cancels itself to idle on the next think. - if (PersistentOrder == OrderType.Follow) - { - Mobile.ControlTarget = _persistentFollowTarget?.Deleted == false - ? _persistentFollowTarget - : Mobile.ControlMaster; - } - - _resolvingOrder = true; - Mobile.ControlOrder = PersistentOrder; - _resolvingOrder = false; + PersistentOrder = order; + _persistentTarget = order == OrderType.Follow ? Mobile.ControlTarget : null; } - public virtual bool Obey() => - !Mobile.Deleted && Mobile.ControlOrder switch + // The standing order is the fallback only for an interrupted order that cannot resume: a + // transient, or an attack whose target is gone. + private OrderType ResumeInterrupted(OrderType previous, Mobile interruptedTarget) + { + if (!IsRestableOrder(previous) || + previous == OrderType.Attack && IsInvalidControlTarget(interruptedTarget)) { - OrderType.None => DoOrderNone(), - OrderType.Come => DoOrderCome(), - OrderType.Drop => DoOrderDrop(), - OrderType.Friend => DoOrderFriend(), - OrderType.Unfriend => DoOrderUnfriend(), - OrderType.Guard => DoOrderGuard(), - OrderType.Attack => DoOrderAttack(), - OrderType.Release => DoOrderRelease(), - OrderType.Stay => DoOrderStay(), - OrderType.Stop => DoOrderStop(), - OrderType.Follow => DoOrderFollow(), - OrderType.Transfer => DoOrderTransfer(), - _ => false + return PersistentOrder; + } + + Mobile.ControlTarget = interruptedTarget; + return previous; + } + + // Resume the standing command without re-deriving it or re-anchoring Home. + private void ResumePersistentOrder() => Mobile.SetControlOrder(PersistentOrder, null, true); + + /// + /// Issue phase. is the only mobile revealed (null = system-issued); + /// marks a fallback to the standing order. Returns the order to rest in. + /// + public virtual OrderType IssueOrder( + OrderType order, OrderType previous, Mobile issuer, bool resuming, Mobile interruptedTarget + ) + { + if (Mobile.Deleted) + { + return order; + } + + AITimer.Prod(); + + issuer?.RevealingAction(); + + // Dropping Warmode nulls Combatant through the Mobile setter, which would turn Attack's + // single Combatant write into a re-write (DoHarmful again) and flap Guard's war stance. + Mobile.FocusMob = null; + + if (!KeepsCombatPosture(order)) + { + Mobile.Warmode = false; // also nulls Combatant via the setter + Mobile.Combatant = null; + } + + return order switch + { + OrderType.None => IssueNone(), + OrderType.Come => IssueCome(), + OrderType.Drop => IssueDrop(previous, interruptedTarget), + OrderType.Friend => IssueFriend(previous, interruptedTarget), + OrderType.Unfriend => IssueUnfriend(previous, interruptedTarget), + OrderType.Guard => IssueGuard(resuming), + OrderType.Attack => IssueAttack(resuming), + OrderType.Release => IssueRelease(), + OrderType.Stay => IssueStay(resuming), + OrderType.Stop => IssueStop(previous), + OrderType.Follow => IssueFollow(resuming), + OrderType.Transfer => IssueTransfer(), + OrderType.Rename => IssueRename(issuer, previous, interruptedTarget), + _ => PersistentOrder // Patrol and anything unimplemented }; + } + + private OrderType IssueNone() + { + Mobile.ControlTarget = null; + Mobile.SetCurrentSpeedToPassive(); + return OrderType.None; + } + + private OrderType IssueCome() + { + Mobile.SetCurrentSpeedToActive(); + return OrderType.Come; + } + + private OrderType IssueStay(bool resuming) + { + Mobile.SetCurrentSpeedToPassive(); + + if (resuming) + { + Mobile.ControlTarget = null; // a transient's target does not carry over + } + else + { + SetPersistentOrder(OrderType.Stay); // anchors Home at the post + Mobile.PlaySound(Mobile.GetIdleSound()); + } + + return OrderType.Stay; + } + + private OrderType IssueFollow(bool resuming) + { + Mobile.SetCurrentSpeedToActive(); + + if (resuming) + { + // the standing Follow's target, never a transient's + Mobile.ControlTarget = _persistentTarget?.Deleted == false ? _persistentTarget : Mobile.ControlMaster; + } + else + { + SetPersistentOrder(OrderType.Follow); // Home = Zero, remembers the target + Mobile.PlaySound(Mobile.GetIdleSound()); + } + + return OrderType.Follow; + } + + private OrderType IssueGuard(bool resuming) + { + Mobile.Warmode = true; // the guard order opens in war stance + Mobile.SetCurrentSpeedToActive(); + + if (resuming) + { + Mobile.ControlTarget = null; + } + else + { + SetPersistentOrder(OrderType.Guard); + Mobile.PlaySound(Mobile.GetAttackSound()); + Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); + // ~1_NAME~ is now guarding you. + } + + return OrderType.Guard; + } + + private OrderType IssueAttack(bool resuming) + { + var target = Mobile.ControlTarget; + var valid = target?.Deleted == false && target.Alive; + + Mobile.FocusMob = valid ? target : null; + Mobile.Combatant = valid ? target : null; // the one Combatant write of the Attack command + + if (valid) + { + Action = ActionType.Combat; + } + + Mobile.Warmode = true; + Mobile.SetCurrentSpeedToActive(); + + // A resumed attack is not a new command: no bark. The Combatant write above is + // idempotent (the setter early-outs unchanged), so its aggression is not repeated. + if (!resuming) + { + Mobile.PlaySound(Mobile.GetAttackSound()); + } + + return OrderType.Attack; + } + + // Stop: Follow/Guard -> idle here; Stay -> keep the post; anything transient -> the standing order. + private OrderType IssueStop(OrderType previous) + { + Mobile.ControlTarget = null; + + switch (previous) + { + case OrderType.Stay: + { + return OrderType.Stay; // resumed: anchor untouched + } + case OrderType.Follow: + case OrderType.Guard: + { + SetPersistentOrder(OrderType.None); // cancel the standing order; idle anchor = here + return OrderType.None; + } + default: + { + // No standing order: idle here, anchored (a Zero Home wanders without bounds). + if (PersistentOrder == OrderType.None) + { + SetPersistentOrder(OrderType.None); + } + + return PersistentOrder; + } + } + } + + private OrderType IssueDrop(OrderType previous, Mobile interruptedTarget) + { + if (!Mobile.IsDeadPet && Mobile.CanDrop) + { + this.DebugSayFormatted($"I am ordered to drop my items by {Mobile.ControlMaster?.Name ?? "Unknown"}."); + DropItems(); + } + + return ResumeInterrupted(previous, interruptedTarget); + } + + private void DropItems() + { + var pack = Mobile.Backpack; + + if (pack == null) + { + return; + } + + var items = pack.Items; + + for (var i = items.Count - 1; i >= 0; --i) + { + if (i < items.Count) + { + items[i].MoveToWorld(Mobile.Location, Mobile.Map); + } + } + } + + private OrderType IssueFriend(OrderType previous, Mobile interruptedTarget) + { + var from = Mobile.ControlMaster; + var to = Mobile.ControlTarget; + + if (from?.Deleted != false) + { + return ResumeInterrupted(previous, interruptedTarget); + } + + var youngFrom = from is PlayerMobile { Young: true }; + var youngTo = to is PlayerMobile { Young: true }; + + if (youngFrom && !youngTo) + { + from.SendLocalizedMessage(502040); + // As a young player, you may not friend pets to older players. + return ResumeInterrupted(previous, interruptedTarget); + } + + if (!youngFrom && youngTo) + { + from.SendLocalizedMessage(502041); + // As an older player, you may not friend pets to young players. + return ResumeInterrupted(previous, interruptedTarget); + } + + if (to?.Deleted != false || from == to || !to.Player) + { + Mobile.PublicOverheadMessage(MessageType.Regular, 0x3B2, 502039); + // *looks confused* + return ResumeInterrupted(previous, interruptedTarget); + } + + if (!from.CanBeBeneficial(to, true)) + { + return ResumeInterrupted(previous, interruptedTarget); + } + + if (from.HasTrade || to.HasTrade) + { + (from.HasTrade ? from : to).SendLocalizedMessage(1070947); + // You cannot friend a pet with a trade pending + return ResumeInterrupted(previous, interruptedTarget); + } + + if (Mobile.IsPetFriend(to)) + { + from.SendLocalizedMessage(1049691); + // That person is already a friend. + return ResumeInterrupted(previous, interruptedTarget); + } + + if (!Mobile.AllowNewPetFriend) + { + from.SendLocalizedMessage(1005482); + // Your pet does not seem to be interested in making new friends right now. + return ResumeInterrupted(previous, interruptedTarget); + } + + from.SendLocalizedMessage(1049676, $"{Mobile.Name}\t{to.Name}"); + // ~1_NAME~ will now accept movement commands from ~2_NAME~. + + to.SendLocalizedMessage(1043246, $"{from.Name}\t{Mobile.Name}"); + // ~1_NAME~ has granted you the ability to give orders to their pet ~2_PET_NAME~. + // This creature will now consider you as a friend. + + Mobile.AddPetFriend(to); + + return ResumeInterrupted(previous, interruptedTarget); + } + + private OrderType IssueUnfriend(OrderType previous, Mobile interruptedTarget) + { + var from = Mobile.ControlMaster; + var to = Mobile.ControlTarget; + + if (from?.Deleted != false || to?.Deleted != false || from == to || !to.Player) + { + Mobile.PublicOverheadMessage(MessageType.Regular, 0x3B2, 502039); + // *looks confused* + return ResumeInterrupted(previous, interruptedTarget); + } + + if (!Mobile.IsPetFriend(to)) + { + from.SendLocalizedMessage(1070953); + // That person is not a friend. + return ResumeInterrupted(previous, interruptedTarget); + } + + from.SendLocalizedMessage(1070951, $"{Mobile.Name}\t{to.Name}"); + // ~1_NAME~ will no longer accept movement commands from ~2_NAME~. + + to.SendLocalizedMessage(1070952, $"{from.Name}\t{Mobile.Name}"); + // ~1_NAME~ has no longer granted you the ability to give orders to their pet ~2_PET_NAME~. + // This creature will no longer consider you as a friend. + + Mobile.RemovePetFriend(to); + + return ResumeInterrupted(previous, interruptedTarget); + } + + private OrderType IssueTransfer() + { + if (Mobile.IsDeadPet) + { + return PersistentOrder; + } + + var from = Mobile.ControlMaster; + var to = Mobile.ControlTarget; + + if (from?.Deleted != false || to?.Deleted != false || from == to || !to.Player) + { + return PersistentOrder; + } + + this.DebugSayFormatted($"Beginning transfer with {to.Name}"); + + var youngFrom = from is PlayerMobile { Young: true }; + var youngTo = to is PlayerMobile { Young: true }; + + if (youngFrom && !youngTo) + { + from.SendLocalizedMessage(502040); + // As a young player, you may not friend pets to older players. + return PersistentOrder; + } + + if (!youngFrom && youngTo) + { + from.SendLocalizedMessage(502041); + // As an older player, you may not friend pets to young players. + return PersistentOrder; + } + + if (!Mobile.CanBeControlledBy(to)) + { + SendTransferRefusalMessages(from, to, 1043248, 1043249); + // 1043248: The pet refuses to be transferred because it will not obey ~1_NAME~.~3_BLANK~ + // 1043249: The pet will not accept you as a master because it does not trust you.~3_BLANK~ + return PersistentOrder; + } + + if (!Mobile.CanBeControlledBy(from)) + { + SendTransferRefusalMessages(from, to, 1043250, 1043251); + // 1043250: The pet refuses to be transferred because it will not obey you sufficiently.~3_BLANK~ + // 1043251: The pet will not accept you as a master because it does not trust ~2_NAME~.~3_BLANK~ + return PersistentOrder; + } + + // The stand-down already cleared Combatant; the aggressor lists and the combat cooldown gate this. + if (Mobile.Aggressors.Count > 0 || Mobile.Aggressed.Count > 0 || Core.TickCount - Mobile.NextCombatTime < 0) + { + from.SendMessage("You can not transfer a pet while in combat."); + to.SendMessage("You can not transfer a pet while in combat."); + return PersistentOrder; + } + + var fromState = from.NetState; + var toState = to.NetState; + + if (fromState == null || toState == null) + { + return PersistentOrder; + } + + if (from.HasTrade || to.HasTrade) + { + from.SendLocalizedMessage(1010507); + // You cannot transfer a pet with a trade pending + to.SendLocalizedMessage(1010507); + // You cannot transfer a pet with a trade pending + return PersistentOrder; + } + + var container = fromState.AddTrade(toState); + container.DropItem(new TransferItem(Mobile)); + + // Hold position while the trade window is open. + Mobile.PlaySound(Mobile.GetIdleSound()); + Mobile.SetCurrentSpeedToPassive(); + SetPersistentOrder(OrderType.Stay); + return OrderType.Stay; + } + + private static void SendTransferRefusalMessages(Mobile from, Mobile to, int fromMessage, int toMessage) + { + var args = $"{to.Name}\t{from.Name}\t "; + + from.SendLocalizedMessage(fromMessage, args); + to.SendLocalizedMessage(toMessage, args); + } + + // SetControlMaster(null) assigns ControlOrder = None underneath; the funnel keeps that write. + private OrderType IssueRelease() + { + if (Mobile.Summoned) + { + Mobile.Kill(); + + // A vetoed death leaves the summon controlled; it keeps its standing order. + return Mobile.Deleted || !Mobile.Alive ? OrderType.None : PersistentOrder; + } + + DebugSay("I have been released to the wild."); + + if (!string.IsNullOrEmpty(Mobile.Name)) + { + Mobile.Name = null; + } + + Mobile.PlaySound(Mobile.GetIdleSound()); + + Mobile.ControlTarget = null; + Mobile.BondingBegin = DateTime.MinValue; + Mobile.OwnerAbandonTime = DateTime.MinValue; + Mobile.IsBonded = false; + // Nothing of the old master survives a re-tame. + Mobile.ClearPetFriends(); + PersistentOrder = OrderType.None; + _persistentTarget = null; + Mobile.SetControlMaster(null); + + var spawner = Mobile.Spawner; + + if (spawner != null) + { + Mobile.Home = spawner.GetSpawnPosition(Mobile, spawner.Map); + Mobile.RangeHome = spawner.WalkingRange; + } + else + { + // No spawner: anchor here rather than path toward a stale stay anchor. + Mobile.Home = Mobile.Location; + Action = ActionType.Wander; + } + + if (Mobile.DeleteOnRelease || Mobile.IsDeadPet) + { + Mobile.Delete(); + } + else + { + Mobile.BeginDeleteTimer(); + + if (Mobile.CanDrop) + { + Mobile.DropBackpack(); + } + } + + return OrderType.None; + } + + protected virtual OrderType IssueRename(Mobile issuer, OrderType previous, Mobile interruptedTarget) + { + var to = issuer ?? Mobile.ControlMaster; + + if (Mobile.Summoned) + { + to?.SendMessage("You cannot rename a summoned creature."); + } + else + { + to?.SendMessage("Change name on pet health bar."); + } + + return ResumeInterrupted(previous, interruptedTarget); + } + + // Only restable orders arrive here; anything else is a pre-refactor save and resumes the standing order. + public virtual bool Obey() + { + if (Mobile.Deleted) + { + return false; + } + + switch (Mobile.ControlOrder) + { + case OrderType.None: + { + return DoOrderNone(); + } + case OrderType.Come: + { + return DoOrderCome(); + } + case OrderType.Guard: + { + return DoOrderGuard(); + } + case OrderType.Attack: + { + return DoOrderAttack(); + } + case OrderType.Stay: + { + return DoOrderStay(); + } + case OrderType.Follow: + { + return DoOrderFollow(); + } + default: + { + ResumePersistentOrder(); + return true; + } + } + } public virtual bool DoOrderNone() { @@ -81,8 +600,7 @@ public abstract partial class BaseAI Mobile.Warmode = IsValidCombatant(Mobile.Combatant); - // Pure idle: gently wander near the anchor, with CheckIdle rest periods. Pets resume - // a standing order via ResumePersistentOrder, not by re-deriving it here. + // A standing order is resumed through ResumePersistentOrder, never re-derived here. WalkRandomIdle(); return true; } @@ -144,166 +662,12 @@ public abstract partial class BaseAI this.DebugSayFormatted($"I am ordered to follow {Mobile.ControlTarget?.Name}."); - // AOS: sprint after the master (bespoke 0.1 paces both clocks). - if (Core.AOS && Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null) - { - Mobile.CurrentSpeed = 0.1; - } - if (currentDistance > 1) { WalkMobileRange(Mobile.ControlTarget, 1, 1, 2); } } - public virtual bool DoOrderDrop() - { - if (Mobile.IsDeadPet || !Mobile.CanDrop) - { - return true; - } - - this.DebugSayFormatted($"I am ordered to drop my items by {Mobile.ControlMaster?.Name ?? "Unknown"}."); - - DropItems(); - ResumePersistentOrder(); - return true; - } - - private void DropItems() - { - var pack = Mobile.Backpack; - - if (pack == null) - { - return; - } - - var items = pack.Items; - - for (var i = items.Count - 1; i >= 0; --i) - { - if (i < items.Count) - { - items[i].MoveToWorld(Mobile.Location, Mobile.Map); - } - } - } - - public virtual bool DoOrderFriend() - { - var from = Mobile.ControlMaster; - var to = Mobile.ControlTarget; - - HandleFriendRequest(from, to); - return true; - } - - private void HandleFriendRequest(Mobile from, Mobile to) - { - var youngFrom = from is PlayerMobile mobile && mobile.Young; - var youngTo = to is PlayerMobile playerMobile && playerMobile.Young; - - if (youngFrom && !youngTo) - { - from.SendLocalizedMessage(502040); - // As a young player, you may not friend pets to older players. - return; - } - - if (!youngFrom && youngTo) - { - from.SendLocalizedMessage(502041); - // As an older player, you may not friend pets to young players. - return; - } - - if (!from.CanBeBeneficial(to, true)) - { - return; - } - - if (to?.Deleted != false || from == to || !to.Player) - { - Mobile.PublicOverheadMessage(MessageType.Regular, 0x3B2, 502039); - // *looks confused* - return; - } - - if (from.HasTrade || to.HasTrade) - { - (from.HasTrade ? from : to).SendLocalizedMessage(1070947); - // You cannot friend a pet with a trade pending - return; - } - - if (Mobile.IsPetFriend(to)) - { - from.SendLocalizedMessage(1049691); - // That person is already a friend. - ResumePersistentOrder(); - return; - } - - if (!Mobile.AllowNewPetFriend) - { - from.SendLocalizedMessage(1005482); - // Your pet does not seem to be interested in making new friends right now. - return; - } - - from.SendLocalizedMessage(1049676, $"{Mobile.Name}\t{to.Name}"); - // ~1_NAME~ will now accept movement commands from ~2_NAME~. - - to.SendLocalizedMessage(1043246, $"{from.Name}\t{Mobile.Name}"); - // ~1_NAME~ has granted you the ability to give orders to their pet ~2_PET_NAME~. - // This creature will now consider you as a friend. - - Mobile.AddPetFriend(to); - - Mobile.ControlTarget = to; - Mobile.ControlOrder = OrderType.Follow; - } - - public virtual bool DoOrderUnfriend() - { - var from = Mobile.ControlMaster; - var to = Mobile.ControlTarget; - - HandleUnfriendRequest(from, to); - return true; - } - - private void HandleUnfriendRequest(Mobile from, Mobile to) - { - if (from?.Deleted != false || to?.Deleted != false || from == to || !to.Player) - { - Mobile.PublicOverheadMessage(MessageType.Regular, 0x3B2, 502039); - // *looks confused* - return; - } - - if (!Mobile.IsPetFriend(to)) - { - from.SendLocalizedMessage(1070953); - // That person is not a friend. - ResumePersistentOrder(); - return; - } - - from.SendLocalizedMessage(1070951, $"{Mobile.Name}\t{to.Name}"); - // ~1_NAME~ will no longer accept movement commands from ~2_NAME~. - - to.SendLocalizedMessage(1070952, $"{from.Name}\t{Mobile.Name}"); - // ~1_NAME~ has no longer granted you the ability to give orders to their pet ~2_PET_NAME~. - // This creature will no longer consider you as a friend. - - Mobile.RemovePetFriend(to); - - Mobile.ControlTarget = from; - Mobile.ControlOrder = OrderType.Follow; - } - public virtual bool DoOrderGuard() { var controlMaster = Mobile.ControlMaster; @@ -337,23 +701,15 @@ public abstract partial class BaseAI var distance = (int)Mobile.GetDistanceToSqrt(controlMaster); - if (distance > 3) - { - // AOS: sprint back (bespoke 0.1 paces both clocks); earlier eras run active. - if (Core.AOS) - { - Mobile.CurrentSpeed = 0.1; - } - else - { - Mobile.SetCurrentSpeedToActive(); - } + // Alert either way; FollowMoveSpeed caps the steps of the return itself. + Mobile.SetCurrentSpeedToActive(); - WalkMobileRange(controlMaster, 1, 1, 3); + if (distance > GuardRange) + { + WalkMobileRange(controlMaster, 1, 1, GuardRange); } else { - Mobile.SetCurrentSpeedToActive(); // alert at the master's side WalkRandom(3, 1, 1); } } @@ -374,10 +730,14 @@ public abstract partial class BaseAI } else { - Mobile.Combatant = Mobile.ControlTarget; - this.DebugSayFormatted($"Attacking target: {Mobile.ControlTarget?.Name}"); + // OnAggressiveAction can swap Combatant; the commanded target wins. + if (Mobile.Combatant != Mobile.ControlTarget) + { + Mobile.Combatant = Mobile.ControlTarget; + } + Think(); } @@ -391,7 +751,6 @@ public abstract partial class BaseAI { DebugSay("Target is either dead, hidden, or out of range."); - Mobile.ControlTarget = Mobile.ControlMaster; ResumePersistentOrder(); // A resumed Guard engages through its own scan; other fallbacks chain an explicit Attack. @@ -405,9 +764,7 @@ public abstract partial class BaseAI if (next != null) { - Mobile.ControlTarget = next; - Mobile.ControlOrder = OrderType.Attack; - Mobile.Combatant = next; + Mobile.IssueOrder(OrderType.Attack, null, next); this.DebugSayFormatted($"{next.Name} is still hostile! Engaging..."); @@ -473,55 +830,6 @@ public abstract partial class BaseAI return best; } - /// - /// The whole release: the master is cleared here, so this runs once, synchronously, from - /// the Release order handler or the loyalty drain, never from Obey. - /// - public virtual bool DoOrderRelease() - { - DebugSay("I have been released to the wild."); - - Mobile.ControlTarget = null; - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; - Mobile.BondingBegin = DateTime.MinValue; - Mobile.OwnerAbandonTime = DateTime.MinValue; - Mobile.IsBonded = false; - Mobile.SetControlMaster(null); - - var spawner = Mobile.Spawner; - - if (spawner != null) - { - Mobile.Home = spawner.GetSpawnPosition(Mobile, spawner.Map); - Mobile.RangeHome = spawner.WalkingRange; - } - else - { - // No spawner to return to: anchor where it stands so it idle-wanders here - // instead of pathing toward a stale (e.g. former stay) anchor. - Mobile.Home = Mobile.Location; - Action = ActionType.Wander; - } - - if (Mobile.DeleteOnRelease || Mobile.IsDeadPet) - { - Mobile.Delete(); - } - else - { - Mobile.BeginDeleteTimer(); - - if (Mobile.CanDrop) - { - Mobile.DropBackpack(); - } - } - - return true; - } - public virtual bool DoOrderStay() { if (CheckHerding()) @@ -542,103 +850,4 @@ public abstract partial class BaseAI return true; } - - // Stop is resolved into another order in OnCurrentOrderChanged and never rests as the - // active order; this is a defensive no-op. - public virtual bool DoOrderStop() => true; - - public virtual bool DoOrderTransfer() - { - if (Mobile.IsDeadPet) - { - return true; - } - - var from = Mobile.ControlMaster; - var to = Mobile.ControlTarget; - - if (from?.Deleted == false && to?.Deleted == false && from != to && to.Player) - { - this.DebugSayFormatted($"Beginning transfer with {to.Name}"); - - var youngFrom = from is PlayerMobile mobile && mobile.Young; - var youngTo = to is PlayerMobile playerMobile && playerMobile.Young; - - if (youngFrom && !youngTo) - { - from.SendLocalizedMessage(502040); - // As a young player, you may not friend pets to older players. - ResumePersistentOrder(); - return true; - } - - if (!youngFrom && youngTo) - { - from.SendLocalizedMessage(502041); - // As an older player, you may not friend pets to young players. - ResumePersistentOrder(); - return true; - } - - if (!Mobile.CanBeControlledBy(to)) - { - SendTransferRefusalMessages(from, to, 1043248, 1043249); - // 1043248: The pet refuses to be transferred because it will not obey ~1_NAME~.~3_BLANK~ - // 1043249: The pet will not accept you as a master because it does not trust you.~3_BLANK~ - ResumePersistentOrder(); - return true; - } - - if (!Mobile.CanBeControlledBy(from)) - { - SendTransferRefusalMessages(from, to, 1043250, 1043251); - // 1043250: The pet refuses to be transferred because it will not obey you sufficiently.~3_BLANK~ - // 1043251: The pet will not accept you as a master because it does not trust ~2_NAME~.~3_BLANK~ - ResumePersistentOrder(); - return true; - } - - if (Mobile.Combatant != null || Mobile.Aggressors.Count > 0 || - Mobile.Aggressed.Count > 0 || Core.TickCount < Mobile.NextCombatTime) - { - from.SendMessage("You can not transfer a pet while in combat."); - to.SendMessage("You can not transfer a pet while in combat."); - ResumePersistentOrder(); - return true; - } - - var fromState = from.NetState; - var toState = to.NetState; - - if (fromState == null || toState == null) - { - ResumePersistentOrder(); - return true; - } - - if (from.HasTrade || to.HasTrade) - { - from.SendLocalizedMessage(1010507); - // You cannot transfer a pet with a trade pending - to.SendLocalizedMessage(1010507); - // You cannot transfer a pet with a trade pending - ResumePersistentOrder(); - return true; - } - - var container = fromState.AddTrade(toState); - container.DropItem(new TransferItem(Mobile)); - } - - Mobile.ControlOrder = OrderType.Stay; - return true; - } - - private static void SendTransferRefusalMessages(Mobile from, Mobile to, int fromMessage, int toMessage) - { - var args = $"{to.Name}\t{from.Name}\t "; - - from.SendLocalizedMessage(fromMessage, args); - to.SendLocalizedMessage(toMessage, args); - } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs index 331df533c..7eac17ae5 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs @@ -161,12 +161,11 @@ internal sealed partial class TransferItem : Item _creature.SummonMaster = to; } - _creature.ControlTarget = to; - _creature.ControlOrder = OrderType.Follow; + _creature.ClearPetFriends(); + _creature.IssueOrder(OrderType.Follow, null, to); _creature.BondingBegin = DateTime.MinValue; _creature.OwnerAbandonTime = DateTime.MinValue; _creature.IsBonded = false; - _creature.PlaySound(_creature.GetIdleSound()); var args = $"{from.Name}\t{_creature.Name}\t{to.Name}"; from.SendLocalizedMessage(1043253, args); diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index f8dba7bf1..b7e893f84 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -1059,9 +1059,6 @@ namespace Server.Mobiles public virtual bool CanDestroyObstacles => false; - // OSI followers were distracted by attacks well into AoS; removed around ML. - public virtual bool CanBeDistracted => !Core.ML; - public override bool ShouldCheckStatTimers => false; public virtual bool CanAngerOnTame => false; @@ -1103,6 +1100,13 @@ namespace Server.Mobiles // (RunUO's forced 0.3, without its TransformMoveDelay inflation to 0.6). private const double HerdingMoveSpeed = 0.3; + /// + /// Seconds per step while closing on the master under a standing order. A cap, not an + /// override: a creature configured faster keeps its own pace. 0 disables it. + /// + [CommandProperty(AccessLevel.GameMaster)] + public virtual double FollowMoveSpeed => Core.AOS ? 0.1 : 0; + [CommandProperty(AccessLevel.GameMaster)] public IPoint2D TargetLocation { @@ -1112,9 +1116,9 @@ namespace Server.Mobiles /// /// Resolved seconds per step: a verbatim active/passive - /// maps to the matching movement value; a bespoke pace (e.g. the pet-order 0.1 sprint) - /// stays fused to both clocks. A herded creature is always driven at - /// . + /// maps to the matching movement value; a bespoke pace stays fused to both clocks. A + /// herded creature is always driven at , and a pet + /// closing on its master is capped at . /// [CommandProperty(AccessLevel.GameMaster)] public double CurrentMoveSpeed @@ -1126,17 +1130,26 @@ namespace Server.Mobiles return HerdingMoveSpeed; } + double speed; + if (_currentSpeed == _activeSpeed) { - return _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed; + speed = _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed; } - - if (_currentSpeed == _passiveSpeed) + else if (_currentSpeed == _passiveSpeed) { - return _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed; + speed = _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed; + } + else + { + speed = _currentSpeed; } - return _currentSpeed; + var followSpeed = FollowMoveSpeed; + + return followSpeed > 0 && AIObject?.IsPacingToMaster() == true + ? Math.Min(followSpeed, speed) + : speed; } } @@ -1188,26 +1201,74 @@ namespace Server.Mobiles } } - // Fires on every assignment, not only changes: a reissued order is a command - // (retarget, break off combat, re-anchor Home). Handlers receive the previous order. + // Fires on every assignment, not only changes: a reissued order is a command (retarget, re-anchor). + // A raw assignment is system-issued; player commands go through IssueOrder. [SerializableProperty(18, useField: nameof(_controlOrder))] [SaveFlag(nameof(ShouldSerializeControlOrder))] [CommandProperty(AccessLevel.GameMaster)] public OrderType ControlOrder { get => _controlOrder; - set + set => SetControlOrder(value, null, false); + } + + /// + /// Gives this pet a command. (null = system-issued) is the only mobile + /// revealed; replaces first. + /// + public void IssueOrder(OrderType order, Mobile issuer, Mobile target = null) + { + // The interrupted order owns this; BaseAI.ResumeInterrupted hands it back. + var interrupted = ControlTarget; + ControlTarget = target; + SetControlOrder(order, issuer, false, interrupted); + } + + // Loops until the Issue phase returns an order that rests. `resuming` = falling back to + // the standing order: no re-derivation, no flourish. + internal void SetControlOrder(OrderType order, Mobile issuer, bool resuming) => + SetControlOrder(order, issuer, resuming, ControlTarget); + + internal void SetControlOrder(OrderType order, Mobile issuer, bool resuming, Mobile interruptedTarget) + { + var ai = AIObject; + var previous = _controlOrder; + _controlOrder = order; + + if (ai != null) { - var previous = _controlOrder; - _controlOrder = value; + for (var depth = 0; ; depth++) + { + var next = ai.IssueOrder(order, previous, issuer, resuming, interruptedTarget); - AIObject?.OnCurrentOrderChanged(previous); + // A nested assignment (SetControlMaster(null), Kill()) already resolved itself; it wins. + if (_controlOrder != order || next == order) + { + break; + } - InvalidateProperties(); + System.Diagnostics.Debug.Assert(depth < 8, "pet order resolution did not converge"); - _controlMaster?.InvalidateProperties(); - this.MarkDirty(); + if (depth >= 8) + { + // Non-converging override: rest at the standing order. + _controlOrder = ai.PersistentOrder; + break; + } + + previous = order; + order = next; + issuer = null; // chained resolutions reveal nobody + resuming = true; + _controlOrder = order; + } + + System.Diagnostics.Debug.Assert(Deleted || BaseAI.IsRestableOrder(_controlOrder), "a transient pet order rested"); } + + InvalidateProperties(); + _controlMaster?.InvalidateProperties(); + this.MarkDirty(); } [CommandProperty(AccessLevel.GameMaster)] @@ -1322,6 +1383,15 @@ namespace Server.Mobiles public static bool BondingEnabled { get; private set; } + /// + /// Publish 51: a pet told to follow, come, stay or stop "will not attack anything, even + /// if it is attacked". Guard and attack are unaffected. The publish has no step of its own + /// on the expansion ladder, so it rides ML and the setting carries the rest. + /// + public static bool PetsStandDownOnCommand { get; private set; } + + public virtual bool StandsDownOnCommand => PetsStandDownOnCommand; + public virtual bool IsBondable => BondingEnabled && !Summoned; public virtual TimeSpan BondingDelay => TimeSpan.FromDays(7.0); public virtual TimeSpan BondingAbandonDelay => TimeSpan.FromDays(1.0); @@ -1875,17 +1945,6 @@ namespace Server.Mobiles BardPacified = false; } - public virtual void CheckDistracted(Mobile from) - { - if (Utility.RandomDouble() < .10) - { - ControlTarget = from; - ControlOrder = OrderType.Attack; - Combatant = from; - Warmode = true; - } - } - public override void OnDamage(int amount, Mobile from, bool willKill) { if (BardPacified && (HitsMax - Hits) * 0.001 > Utility.RandomDouble()) @@ -1927,21 +1986,11 @@ namespace Server.Mobiles ReceivedHonorContext?.OnTargetDamaged(from, amount); - if (!willKill && CanBeDistracted && ControlOrder == OrderType.Follow) - { - CheckDistracted(from); - } - base.OnDamage(amount, from, willKill); } public virtual void OnDamagedBySpell(Mobile from, int damage) { - if (CanBeDistracted && ControlOrder == OrderType.Follow) - { - CheckDistracted(from); - } - TriggerAbility(MonsterAbilityTrigger.TakeSpellDamage, from); } @@ -2688,7 +2737,7 @@ namespace Server.Mobiles if (AIObject != null) { - if (!Core.ML || ct != OrderType.Follow && ct != OrderType.Stop && ct != OrderType.Stay) + if (!StandsDownOnCommand || !BaseAI.IsStandDownOrder(ct)) { AIObject.OnAggressiveAction(aggressor); } @@ -2714,11 +2763,10 @@ namespace Server.Mobiles } } - if (aggressor.ChangingCombatant && (_controlled || _summoned) && - (ct == OrderType.Come || !Core.ML && ct == OrderType.Stay || ct is OrderType.Stop or OrderType.None or OrderType.Follow)) + // Only reachable when the pet does not stand down: the orders above returned early. + if (aggressor.ChangingCombatant && (_controlled || _summoned) && BaseAI.IsStandDownOrder(ct)) { - ControlTarget = aggressor; - ControlOrder = OrderType.Attack; + IssueOrder(OrderType.Attack, null, aggressor); } else if (Combatant == null && !BardPacified) { @@ -3419,8 +3467,7 @@ namespace Server.Mobiles Mana = 0; IsDeadPet = true; - ControlTarget = ControlMaster; - ControlOrder = OrderType.Follow; + IssueOrder(OrderType.Follow, null, ControlMaster); ProcessDelta(); SendIncomingPacket(); @@ -4159,6 +4206,7 @@ namespace Server.Mobiles public static void Configure() { BondingEnabled = ServerConfiguration.GetSetting("taming.enableBonding", Core.LBR); + PetsStandDownOnCommand = ServerConfiguration.GetSetting("taming.petsStandDownOnCommand", Core.ML); } public void BeginDeleteTimer() @@ -4239,12 +4287,20 @@ namespace Server.Mobiles public virtual void AddPetFriend(Mobile m) { - Friends ??= new List(); - + Friends ??= []; Friends.Add(m); + this.MarkDirty(); } - public virtual void RemovePetFriend(Mobile m) => Friends?.Remove(m); + public virtual void RemovePetFriend(Mobile m) + { + if (Friends?.Remove(m) == true) + { + this.MarkDirty(); + } + } + + public virtual void ClearPetFriends() => Friends = null; // generated setter marks dirty public virtual bool IsFriend(Mobile m) => OppositionGroup?.IsEnemy(this, m) != true && m is BaseCreature c && _team == c._team @@ -5923,7 +5979,7 @@ namespace Server.Mobiles c.Say(1043255, c.Name); // ~1_NAME~ appears to have decided that is better off without a master! c.Loyalty = BaseCreature.MaxLoyalty; - c.AIObject.DoOrderRelease(); + c.ControlOrder = OrderType.Release; } while (toRemove.Count > 0) diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs index 7706115f5..ff40211e8 100644 --- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs +++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs @@ -93,8 +93,6 @@ namespace Server.Mobiles public override FoodType FavoriteFood => FoodType.None; - public override bool CanBeDistracted => false; - public override string DefaultName => "a golem"; public override bool DeleteOnRelease => true; diff --git a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/SerpentineDragon.cs b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/SerpentineDragon.cs index 0d63142f2..9c174f07a 100644 --- a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/SerpentineDragon.cs +++ b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/SerpentineDragon.cs @@ -86,9 +86,7 @@ namespace Server.Mobiles if (!Core.SE && Utility.RandomDouble() < 0.2 && attacker is BaseCreature c && c.Controlled && c.ControlMaster != null) { - c.ControlTarget = c.ControlMaster; - c.ControlOrder = OrderType.Attack; - c.Combatant = c.ControlMaster; + c.IssueOrder(OrderType.Attack, null, c.ControlMaster); } } } diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index 6e0902b64..55aa32605 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -27,7 +27,8 @@ description: > (`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move (`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think - AND clears move overrides, `SetMoveSpeed()` sets move only. The client `Running` bit is + AND clears move overrides, `SetMoveSpeed()` sets move only. Herding and pacing to a + master (`FollowMoveSpeed`) cap the resolved pace without writing either clock. The client `Running` bit is derived from the step pace (`BaseAI.ShouldRun`); movement APIs take no run argument -- see `dev-docs/content-patterns.md` § Creature Speeds. Reaction time to approaching enemies is `AcquireOnApproachDelay` (TimeSpan gradient; `Zero` = paragon snap, 2s diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index f1df32d42..d2d301306 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -284,6 +284,17 @@ ClearMoveSpeed(); // back to inheriting the think clock All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity). +Two conditions cap the resolved step pace without touching either clock, so nothing is +stored and nothing needs undoing when the condition ends: + +- **Herding** — a creature with a `TargetLocation` is driven at a fixed `HerdingMoveSpeed`. +- **Pacing to the master** — a pet following its master, or guarding from outside guard + range, is capped at `FollowMoveSpeed` (AOS 0.1, earlier eras 0; RunUO's pet sprint). It is + a cap, not an override: a creature configured faster keeps its own pace, and its + `ActiveMoveSpeed`/`PassiveMoveSpeed` are left untouched. Override the virtual to change + the pace or to enable it in an era that has it off. Decisions are unaffected — a following + pet thinks on its active clock. + The client's `Running` bit is derived from the step pace, never passed by callers (`BaseAI.ShouldRun`, stamped in `DoMoveImpl`): a step shorter than the client's walk interpolation — 400 ms on foot, 200 ms mounted/flying (`Movement.WalkFootDelay` /