From f606225f476d8b91e247e8b939a66770bd468d04 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:28:13 -0700 Subject: [PATCH] fix: pets freeze, forget their orders, obey the wrong players, and fight when told not to (#2614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Pet orders lived in two files with nothing enforcing which phase owned what: `PetOrderHandlers.cs` ran one-shot handlers inside the `ControlOrder` setter and `PetOrders.cs` ran `DoOrderXxx` every AI tick from `Obey`. Friend/Unfriend refusals repeated their message every tick, Rename froze the pet, Drop on a dead pet never ended, the loyalty drain bypassed the release handler, and the command issuer leaked through a public field that only some handlers cleared (#2613 fixed the Release casualty of that split; this finishes the job). Every order now lives in one place, `PetOrders.cs`, with two named phases: - **Issue** — `BaseAI.IssueOrder(order, previous, issuer, resuming, interruptedTarget)` runs once, synchronously, from the new `BaseCreature.SetControlOrder` funnel. It may only set state and emit (message, sound, reveal) and returns the order to rest in. The funnel loops to a fixed point, so transient orders (Drop, Friend, Unfriend, Transfer, Release, Rename, Stop, Patrol) resolve before the setter returns and can never rest. - **Tick** — `DoOrderXxx` runs from `Obey` for the six restable orders only (None, Come, Guard, Attack, Stay, Follow). Anything else that arrives there came from an old save and falls back to the standing order. The issuer is a parameter: `BaseCreature.IssueOrder(order, issuer, target)` is the entry for player commands (speech, context menu, targeting), a raw `ControlOrder = x` assignment is a system-issued order, and nothing has to remember to clear anything. A resumed Follow restores the mobile the standing Follow was following, never a transient's target. Because the funnel is synchronous it also carries the order being interrupted, so an administrative command can hand control back to what the pet was doing without storing anything per creature. ## Era behaviour, with sources Two publishes govern most of the questions here, and the inherited code matched neither exactly. [**Publish 16**](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2002-2/publish-16-part-2-4-23rd-july/) (23 July 2002) — *"The 'stop' command will stop a pet from guarding, following, and attacking."* Stop cancels the current attack and leaves the pet idle but still reactive. That is what this branch does whenever the stand-down policy below is off, in every era. (The same publish's *"Friends will only be able to issue movement commands to pets"* is the rule already enforced by `IsFriendOrder`.) [**Publish 51**](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2008-2/publish-51-26th-march/) (26 March 2008) lists it per command: > Follow: The pet should follow. It will not attack anything, even if it is attacked. > > Come: The pet should come. It will not attack anything, even if it is attacked. > > Stay: The pet will stay where it is currently, and will not attack anything, even if it is attacked. > > Stop: The pet will stop attacking. It will not attack anything, even if it is attacked, and may wander. > > Guard: The pet should guard as it does currently. > > Kill/Attack: The pet will attack its target as it does currently. The inherited rule covered Follow and Stay only, so a pet told to come fought back, and it could not cover Stop at all: Stop resolves to None rather than resting, and None is exactly the state the publish describes. `BaseAI.IsStandDownOrder` now names the set — Follow, Come, Stay, None — and both halves of `AggressiveAction` read it. ## Configuration `taming.petsStandDownOnCommand` (default `Core.ML`) controls the Publish 51 behaviour. The publish has no step of its own on the expansion ladder — it lands between ML and SA, and Kingdom Reborn was a client rather than an expansion — so it keeps riding ML as before, and the setting carries the rest: the behaviour is popular well outside its era, so a shard on AOS that wants it sets the key, and one that does not clears it. `BaseCreature.StandsDownOnCommand` is virtual for a creature that should differ. ## Bugs fixed along the way - Friend/Unfriend refusal spam (every tick until the next command); Rename freezing the pet; Drop on a dead or non-`CanDrop` pet freezing the pet. - Loyalty-zero release skipping the name clear and the summoned kill; a released pet keeping its `Friends` list and its previous owner's standing order. - A transferred pet still answering to the previous owner's friends; transfer playing the idle sound twice. - `BaseTalisman` summons issued `Friend` with no target (*looks confused* forever, or young-player spam); they follow their owner. - Speech: single-pet commands lost their name gate after #2232 (a bare "come" moved every pet in range; "all stay" issued Stay twice); the speech cases passed a hardcoded `isOwner: true`, so a pet friend could say "`` drop" and dump the pack, or issue Come/Guard. - GM "`` obey" was unreachable for a controlled pet; context-menu Release and speech Release disagreed about the control roll (resolved by removing it from both, below). - Login derived the standing order from proximity even for a pet saved on Stay (zeroing its post), and only recorded the derived order without issuing it, so a pet saved mid-transient idled after a restart. - `Friends` mutations never marked the creature dirty for delta saves. - A resumed standing Follow was left without a target and cancelled itself to idle on the next think — the same defect as #2616, fixed here by `IssueFollow(resuming)` restoring the remembered target. ## Behaviour changes a shard maintainer will notice - Every player command reveals its issuer, including context-menu commands from a hidden owner. This restores the blanket reveal (RunUO reveals in every order arm) minus its bug: it revealed the **control master**, so a friend's command popped the owner wherever they stood. Speech already reveals through `Mobile.OnSaid`, so the practical change is the context menu, target picks and the release gump. - Resumed/chained orders are silent (no idle sound when falling back after Drop, Stop, a refused Friend, etc.). - **Administrative commands no longer call the pet off.** Drop, Friend, Unfriend and Rename keep the pet's combat posture and hand control back to the order they interrupted, target and all; the standing order is the fallback only when the interrupted order cannot resume (a transient, or an attack whose target died, left or hid). Resuming an attack does not repeat its aggression or replay its bark. - **Friend and Unfriend no longer rewrite the standing order.** Previously a success pointed the pet at the new friend and made Follow its standing order, so a pet left on Stay silently became a Follow with its anchor cleared. Friending grants a permission and nothing else; the friend has movement commands and can ask the pet to follow. - **Releasing a pet no longer rolls the control chance**, on either path. A refused roll cost 3 loyalty, and loyalty reaching zero releases the pet anyway, so refusing only converted a deliberate release into an involuntary one minutes later. Both paths gate on `CanBeControlledBy` instead: if you can command it, you can dismiss it. - **The pet distraction roll is gone.** A pet on Follow had a 10% chance per damage callback of dropping the order and attacking whoever hit it, issued without consulting anything, so it overrode the stand-down policy a few hits after the aggression path had correctly ignored it. Its era gate was guesswork by its own comment's admission and no publish describes it; `CanBeDistracted`, `CheckDistracted`, both call sites and the `Golem` override are deleted. Pre-ML shards lose the mechanic. - **A pet's follow pace moved off the think clock.** The AOS sprint wrote a bespoke `CurrentSpeed = 0.1`, which fused both clocks — discarding any configured `ActiveMoveSpeed`/`PassiveMoveSpeed` — and pinned a following pet's AI at 10 Hz even while standing still. `BaseCreature.FollowMoveSpeed` (virtual, AOS 0.1) now caps the resolved step delay while the pet is closing on its master, the same way herding does: nothing stored, and a creature configured faster keeps its own pace. - Transfer with an invalid target is a refusal (resumes) instead of forcing Stay; the transfer combat gate rests on the aggressor lists and `NextCombatTime`. - Stop with no standing order anchors the idle where the pet stands (a vendor-bought pet no longer wanders off unbounded). - The old "master must be alive" bails in the handlers are gone; stand-down and sounds run for orphaned pets too. RunUO gates only on the master being null or deleted, and a living friend commanding a dead owner's pet could not previously call it out of a fight. - Login: a pet at None near its master is issued Follow silently; a saved Stay/Follow/Guard is adopted as is. - GM "all obey" only reaches wild creatures; a controlled pet must be named. - Death still issues Follow with the idle sound, as RunUO did. ## Tests `PetOrderTests` grew from 16 to 63, plus 13 in a new `PetRetaliationTests` for the Publish 51 matrix and 13 in `PetPacingTests` for the clocks. Together they cover order resolution, reveal on every entry path, release parity (player vs drain, summoned), transfer/friend refusals and successes, stand-down and war-mode invariants, the interrupted-order resume, speech gating and permissions, GM obey, login derivation, the load probe, and the retaliation matrix across eras and both damage callbacks. Whole project green: 845 `UOContent.Tests`, 869 `Server.Tests`. --- .../Tests/Mobiles/AI/GuardFollowTests.cs | 5 +- .../Tests/Mobiles/AI/PetOrderTests.cs | 728 +++++++++++++- .../Tests/Mobiles/AI/PetPacingTests.cs | 123 ++- .../Tests/Mobiles/AI/PetRetaliationTests.cs | 164 +++ .../Tests/Mobiles/AI/PetTestStub.cs | 17 + .../UOContent/Gumps/ConfirmReleaseGump.cs | 3 +- .../UOContent/Items/Talismans/BaseTalisman.cs | 2 +- .../UOContent/Mobiles/AI/BaseAI/AIMovement.cs | 18 + .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 16 +- .../Mobiles/AI/BaseAI/InternalEntry.cs | 16 +- .../UOContent/Mobiles/AI/BaseAI/OnSpeech.cs | 110 ++- .../UOContent/Mobiles/AI/BaseAI/PetLogin.cs | 33 +- .../Mobiles/AI/BaseAI/PetOrderHandlers.cs | 280 ------ .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 933 +++++++++++------- .../Mobiles/AI/BaseAI/TransferItem.cs | 5 +- Projects/UOContent/Mobiles/BaseCreature.cs | 160 ++- .../Mobiles/Monsters/Misc/Melee/Golem.cs | 2 - .../Reptile/Magic/SerpentineDragon.cs | 4 +- .../modernuo-content-patterns.md | 3 +- dev-docs/content-patterns.md | 11 + 20 files changed, 1841 insertions(+), 792 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs delete mode 100644 Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs 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` /