From f8d2a2bacc8e454e2d7dcbc08e61bb37dc82144f Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:18:11 -0700 Subject: [PATCH] fix(ai): wild creatures no longer stand down when attacked (#2645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Symptom Since #2614, on any shard with `taming.petsStandDownOnCommand` (default `Core.ML`), every wild creature hits `"I'm being attacked but my master told me not to fight."` when struck. Brigands still chase (acquisition is a separate path in the think loop) but the retaliation path is dead: no `OnAggressiveAction`, no `StopFlee`, no `ForceReacquire`, `Combatant` never set, and `Warmode` forced off on every hit. ## Root cause #2614 correctly added `OrderType.None` to `BaseAI.IsStandDownOrder` — a stopped pet rests on `None` and Publish 51 says it must not fight back. But the gate in `BaseCreature.AggressiveAction` never asked whether anybody could have given the order. The old predicate (`ct != Follow && ct != Stop && ct != Stay`) had only excluded wild creatures by accident: `None` was not in its set, so nothing ever needed to spell the check out. A wild creature's `_controlOrder` is always `None`. ## Fix Both halves of `AggressiveAction` now gate on `Controlled && ControlMaster != null && Commandable` — the same predicate every order entry point already uses (`OnSpeech`, context menu, `IsValidTarget`). Only a creature somebody can command has been told to stand down. That excludes, and lets fight back: | Creature | Why it was standing down | |---|---| | Wild creature | rests on `None` | | Energy vortex, blade spirits, animated weapon, animate dead | `Summoned` with a `SummonMaster` but never `Controlled`; rests on `None` | | Familiar, talisman summon, escortee, mirror image | `Controlled` with a master but `Commandable => false`; sits on a system-issued `Follow` | The last row is a deliberate behaviour change from #2614 for ML+ shards: a familiar or escortee on `Follow` no longer stands down when attacked. The publish speaks of commanded pets; a creature that cannot take an order was never told anything, and pre-ML it always fought back. Commandable summons (Summon Creature, elementals, daemons) are `Controlled` with `ControlMaster == SummonMaster`, so they stand down exactly as pets do. ## Also: stop after stay Found while testing: `all come` then `all stop` left the pet on `Stay`, ticking *"I have been ordered to stay"*. `come` rests into `Stay` on arrival (deliberate ModernUO divergence, kept), and #2614 had `IssueStop` keep a previous `Stay`. RunUO and ServUO never consult the previous order — `DoOrderStop` is "wander around here" (or `None` pre-ML) — and Publish 51 says a stopped pet *"may wander"*. `Stay` now joins `Follow`/`Guard` in `IssueStop`: stop cancels the standing order and the pet idles anchored where it stands. `Stop_WhileStaying_RemainsStayingAtOriginalPost` is replaced by `Stop_WhileStaying_CancelsToIdleNone` plus the `come, stop` repro. ## Tests `PetRetaliationTests` gains one case per row above: `WildCreature_`, `UncontrolledSummon_`, `UncommandableCreature_Retaliates_UnderStandDown`. Each fails on main and passes here; full `UOContent.Tests` green (1058 passed). --- .../Tests/Mobiles/AI/PetOrderTests.cs | 25 +++++++- .../Tests/Mobiles/AI/PetRetaliationTests.cs | 62 +++++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 7 +-- Projects/UOContent/Mobiles/BaseCreature.cs | 22 +++---- 4 files changed, 95 insertions(+), 21 deletions(-) diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs index 91acee5d9..ca30ee7d9 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs @@ -133,8 +133,10 @@ public class PetOrderTests : IDisposable Assert.Equal(pet.Location, pet.Home); // idle anchor = where stopped } + // Publish 51: "Stop: ... may wander." A stay is a standing order like follow or guard; + // stop cancels it and the pet idles, anchored where it stands. [Fact] - public void Stop_WhileStaying_RemainsStayingAtOriginalPost() + public void Stop_WhileStaying_CancelsToIdleNone() { var post = new Point3D(1005, 1005, 0); var (_, pet) = Spawn(new Point3D(1000, 1000, 0), post); @@ -143,9 +145,26 @@ public class PetOrderTests : IDisposable pet.ControlOrder = OrderType.Stop; + Assert.Equal(OrderType.None, pet.ControlOrder); + Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder); + Assert.Equal(pet.Location, pet.Home); // idle anchor = where stopped + } + + // "all come" rests into Stay on arrival; "all stop" must not leave the pet at that post. + [Fact] + public void Come_ThenArrive_ThenStop_IdlesRatherThanStaying() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + Assert.Equal(OrderType.Come, pet.ControlOrder); + + pet.AIObject.Obey(); // within 2 tiles -> Stay Assert.Equal(OrderType.Stay, pet.ControlOrder); - Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder); - Assert.Equal(post, pet.Home); // post unchanged + + pet.ControlOrder = OrderType.Stop; + + Assert.Equal(OrderType.None, pet.ControlOrder); + Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder); + Assert.Equal(pet.Location, pet.Home); } [Fact] diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs index 00316a351..d5074704c 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs @@ -161,4 +161,66 @@ public class PetRetaliationTests : IDisposable Assert.Same(attacker, pet.Combatant); Assert.True(pet.Warmode); } + + private sealed class UncommandablePet : PetTestStub + { + public override bool StandsDownOnCommand => true; + public override bool Commandable => false; + } + + // The publish speaks of commanded pets. A creature nobody can give an order to was never + // told anything, yet it rests on None (wild, uncontrolled summon) or a system-issued Follow + // (familiar, escortee) - the same orders a pet stands down on. It must still fight back. + [Fact] + public void WildCreature_Retaliates_UnderStandDown() + { + var creature = new StandDownPet(); + creature.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); + creature.AIObject.AITimer?.Stop(); + _created.Add(creature); + + Assert.Equal(OrderType.None, creature.ControlOrder); + + var attacker = Attack(creature); + + Assert.Same(attacker, creature.Combatant); + Assert.True(creature.Warmode); + } + + [Fact] // energy vortex, blade spirits: Summoned with a SummonMaster, never Controlled + public void UncontrolledSummon_Retaliates_UnderStandDown() + { + var caster = new PlayerMobile(World.NewMobile); + caster.DefaultMobileInit(); + caster.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + _created.Add(caster); + + var summon = new StandDownPet(); + summon.Summoned = true; + summon.SummonMaster = caster; + summon.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); + summon.AIObject.AITimer?.Stop(); + _created.Add(summon); + + Assert.False(summon.Controlled); + Assert.Equal(OrderType.None, summon.ControlOrder); + + var attacker = Attack(summon); + + Assert.Same(attacker, summon.Combatant); + Assert.True(summon.Warmode); + } + + [Fact] // familiar, escortee: Controlled with a master, but not Commandable + public void UncommandableCreature_Retaliates_UnderStandDown() + { + var (master, familiar) = Spawn(); + familiar.ControlTarget = master; + familiar.ControlOrder = OrderType.Follow; // system-issued, not a command + + var attacker = Attack(familiar); + + Assert.Same(attacker, familiar.Combatant); + Assert.True(familiar.Warmode); + } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 1e751e9ab..1a166d493 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -218,19 +218,16 @@ public abstract partial class BaseAI return OrderType.Attack; } - // Stop: Follow/Guard -> idle here; Stay -> keep the post; anything transient -> the standing order. + // Stop: Follow/Guard/Stay -> idle here; 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: + case OrderType.Stay: { SetPersistentOrder(OrderType.None); // cancel the standing order; idle anchor = here return OrderType.None; diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index be4eea9e9..6aa05c1f7 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -2736,22 +2736,18 @@ namespace Server.Mobiles aggressor.Aggressors.Add(AggressorInfo.Create(this, aggressor, true)); } - var ct = _controlOrder; + var toldToStandDown = _controlled && _controlMaster != null && Commandable && + BaseAI.IsStandDownOrder(_controlOrder); - if (AIObject != null) + if (StandsDownOnCommand && toldToStandDown) { - if (!StandsDownOnCommand || !BaseAI.IsStandDownOrder(ct)) - { - AIObject.OnAggressiveAction(aggressor); - } - else - { - AIObject.DebugSay("I'm being attacked but my master told me not to fight."); - Warmode = false; - return; - } + AIObject?.DebugSay("I'm being attacked but my master told me not to fight."); + Warmode = false; + return; } + AIObject?.OnAggressiveAction(aggressor); + StopFlee(); ForceReacquire(); @@ -2767,7 +2763,7 @@ namespace Server.Mobiles } // Only reachable when the pet does not stand down: the orders above returned early. - if (aggressor.ChangingCombatant && (_controlled || _summoned) && BaseAI.IsStandDownOrder(ct)) + if (aggressor.ChangingCombatant && toldToStandDown) { IssueOrder(OrderType.Attack, null, aggressor); }