From 5d00149dd8d0d7f686f7e02d38dc518504c7e699 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:06:09 -0700 Subject: [PATCH] fix: keep the Guard order through combat and sprint guard returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guarding pet's engagement path (FindCombatant) rewrote ControlOrder to Attack, so guard silently converted to a plain kill order mid-fight (#2595): the (guarding)/guarded OPL tags vanished, the pet stopped scanning for the master's threats and never retargeted, recall/gate left it behind (TeleportPets only takes Guard/Follow/Come), and every engage->kill->resume cycle replayed the "is now guarding you" flourish. Return pacing also depended on stale Warmode, leaving guard returns active or passive by combat history. - FindCombatant is now FindGuardTarget: a pure selector that prefers the aggressor closest to the master (RunUO guard parity) and keeps the current combatant unless a strictly closer one exists. DoOrderGuard engages through it without ever leaving the Guard order. - HandleInvalidControlTarget resumes the persistent order first and chains the next aggressor into an explicit Attack only for non-guard fallbacks — an explicit "all attack" completes, returns to guarding, and the guard scan takes over. Resuming Guard no longer replays the sound/message flourish. - The peaceful guard branch stands down (Warmode/Combatant/FocusMob cleared) so pacing is deterministic, and guard returns sprint at the follow pace (0.1s/step, AOS) on the move clock while the think cadence is untouched. - WalkMobileRange honors the caller's run flag instead of a hardcoded distance-5 gate; follow/guard run animation now matches their actual pace. Closes #2595 Co-Authored-By: Claude Fable 5 --- .../Tests/Mobiles/AI/GuardOrderTests.cs | 140 ++++++++++++++++++ .../Tests/Mobiles/AI/PetPacingTests.cs | 42 +++++- .../UOContent/Mobiles/AI/BaseAI/AIMovement.cs | 13 +- .../Mobiles/AI/BaseAI/PetOrderHandlers.cs | 13 +- .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 102 ++++++++----- Projects/UOContent/Mobiles/BaseCreature.cs | 8 +- 6 files changed, 265 insertions(+), 53 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs new file mode 100644 index 000000000..c93a8ed7a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Pins guard-order combat semantics (issue #2595): a guarding pet engages and fights +// without ever leaving the Guard order, retargets toward the master's closest aggressor, +// and stands down deterministically when there is nothing to guard against. The scene +// sits on the proven-open (1495..1500, 1600) Trammel segment from ApproachTargetTests; +// combat targets are adjacent so no pathfinding runs. +[Collection("Sequential UOContent Tests")] +public class GuardOrderTests : IDisposable +{ + private readonly List _created = new(); + + private sealed class AggressorStub : Mobile + { + public AggressorStub() => Body = 0xC9; + } + + public void Dispose() + { + foreach (var m in _created) + { + m?.Delete(); + } + + _created.Clear(); + } + + private (PlayerMobile master, PetTestStub pet) SpawnGuardingPet(out Map map, out int z) + { + map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out z, out _); + + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + _created.Add(master); + + var pet = new PetTestStub(); + pet.MoveToWorld(new Point3D(1499, 1600, (sbyte)z), map); + pet.SetControlMaster(master); + _created.Add(pet); + + pet.AIObject.AITimer?.Stop(); // drive manually + pet.ControlOrder = OrderType.Guard; + pet.AIObject.AITimer?.Stop(); // the order change restarts the timer + + return (master, pet); + } + + private AggressorStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking) + { + var aggr = new AggressorStub(); + aggr.MoveToWorld(loc, pet.Map); + _created.Add(aggr); + + // Guards the test setup itself: the scene must stay on open, LOS-clear terrain + // and the combatant assignment must not be vetoed. + Assert.True(pet.InLOS(aggr), $"no LOS from pet to aggressor at {loc}"); + + if (attacking != null) + { + aggr.Combatant = attacking; + Assert.Same(attacking, aggr.Combatant); + } + + return aggr; + } + + [Fact] + public void GuardEngage_KeepsGuardOrder() + { + var (master, pet) = SpawnGuardingPet(out _, out var z); + var aggr = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master); + + pet.AIObject.Obey(); + + Assert.Same(aggr, pet.Combatant); + Assert.Equal(OrderType.Guard, pet.ControlOrder); + Assert.Equal(OrderType.Guard, pet.AIObject.PersistentOrder); + } + + [Fact] + public void Guard_RetargetsToAggressorClosestToMaster() + { + var (master, pet) = SpawnGuardingPet(out _, out var z); + var far = SpawnAggressor(pet, new Point3D(1495, 1600, (sbyte)z), master); + var near = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master); + + pet.Combatant = far; // already fighting the far aggressor + + pet.AIObject.Obey(); + + Assert.Same(near, pet.Combatant); // defends the master, not the current fight + Assert.Equal(OrderType.Guard, pet.ControlOrder); + } + + [Fact] + public void ExplicitAttack_ResumesGuard_WithoutChainingIntoAttack() + { + var (master, pet) = SpawnGuardingPet(out _, out var z); + + // Explicit kill order on a target that then becomes invalid. + var victim = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), null); + pet.ControlTarget = victim; + pet.ControlOrder = OrderType.Attack; + victim.Hidden = true; + + // A second aggressor is still after the master; FightMode.Closest would chain it. + var aggr2 = SpawnAggressor(pet, new Point3D(1497, 1600, (sbyte)z), master); + + pet.AIObject.Obey(); // attack completes -> resume the persistent Guard + + Assert.Equal(OrderType.Guard, pet.ControlOrder); + + pet.AIObject.Obey(); // the guard scan engages the remaining aggressor in-order + + Assert.Same(aggr2, pet.Combatant); + Assert.Equal(OrderType.Guard, pet.ControlOrder); + } + + [Fact] + public void PeacefulGuard_StandsDown() + { + var (_, pet) = SpawnGuardingPet(out _, out _); + Assert.True(pet.Warmode); // the guard order opens in war stance + + pet.AIObject.Obey(); // nothing to guard against + + Assert.False(pet.Warmode); + Assert.Null(pet.Combatant); + Assert.Null(pet.FocusMob); + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs index ee47a3348..abaae1aab 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs @@ -42,14 +42,50 @@ public class PetPacingTests : IDisposable Assert.Equal(OrderType.Come, pet.ControlOrder); Assert.Equal(0.4, pet.CurrentMoveSpeed); - pet.ControlOrder = OrderType.Guard; - Assert.Equal(0.4, pet.CurrentMoveSpeed); - pet.ControlTarget = master; pet.ControlOrder = OrderType.Follow; Assert.Equal(0.4, pet.CurrentMoveSpeed); } + // A guarding pet with nothing to fight returns to its master at the follow sprint + // pace (RunUO guard parity) while its think cadence stays untouched. + [Fact] + public void GuardReturn_SprintsOnMoveClock() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + pet.SetCurrentSpeedToPassive(); + + pet.ControlOrder = OrderType.Guard; // fixture era is EJ: Core.AOS is true + + Assert.Equal(0.1, pet.CurrentMoveSpeed); + Assert.Equal(0.4, pet.CurrentSpeed); // think clock unaffected + } + + // Boundary guard: pre-AOS eras have no sprint — guard paces on the think clock. + [Fact] + public void GuardReturn_PreAOS_PacesThinkClock() + { + var previous = Core.Expansion; + + try + { + Core.Expansion = Expansion.UOR; + + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + pet.SetCurrentSpeedToPassive(); + + pet.ControlOrder = OrderType.Guard; + + Assert.Equal(0.4, pet.CurrentMoveSpeed); + } + finally + { + Core.Expansion = previous; + } + } + // Boundary guard: a pet chasing a combatant keeps the move table. [Fact] public void CombatChasingPet_KeepsMoveTable() diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index ccd1c72b6..1a58ac4d2 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -649,14 +649,12 @@ public abstract partial class BaseAI { var iCurrDist = (int)Mobile.GetDistanceToSqrt(m); - var shouldRun = run && iCurrDist > 5; - if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax) { return true; } - if (!MoveTowardsOrAwayFrom(m, shouldRun, iCurrDist, iWantDistMax)) + if (!MoveTowardsOrAwayFrom(m, run, iCurrDist, iWantDistMax)) { return false; } @@ -667,18 +665,19 @@ public abstract partial class BaseAI return dist >= iWantDistMin && dist <= iWantDistMax; } + // The caller's run flag is honored as-is: it only sets the client-side animation + // (server pace is the move budget), and the callers that pass anything but false — + // follow, guard, clone — gate it on their own distance thresholds. private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax) { - var shouldRun = run && iCurrDist > 5; - if (iCurrDist > iWantDistMax) { // Too far: approach via the centralized progress-based primitive. - return ApproachTarget(m, shouldRun, iWantDistMax); + return ApproachTarget(m, run, iWantDistMax); } // Too close: back away. Retreat keeps the simple greedy behavior (out of scope). - if (DoMove(m.GetDirectionTo(Mobile, shouldRun), true)) + if (DoMove(m.GetDirectionTo(Mobile, run), true)) { Path = null; return true; diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs index 0b7bdd0b1..4c8aa10d3 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs @@ -163,9 +163,16 @@ public abstract partial class BaseAI _commandIssuer?.RevealingAction(); Mobile.FocusMob = null; Mobile.Warmode = true; - Mobile.PlaySound(Mobile.GetAttackSound()); - Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); - // ~1_NAME~ is now guarding you. + + // Only a freshly issued command plays the flourish; resuming the persistent + // order after an explicit attack must not replay it after every kill. + if (!_resolvingOrder) + { + Mobile.PlaySound(Mobile.GetAttackSound()); + Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); + // ~1_NAME~ is now guarding you. + } + _commandIssuer = null; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 176463c50..e9a101b89 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -291,14 +291,15 @@ public abstract partial class BaseAI return true; } - FindCombatant(); + var combatant = FindGuardTarget(); - if (IsValidCombatant(Mobile.Combatant)) + if (combatant != null) { - var combatant = Mobile.Combatant; - this.DebugSayFormatted($"Attacking target: {combatant.Name}"); + // Engage without leaving the Guard order (#2595): the (guarding)/guarded + // tags persist, recall/gate keeps the pet, and the per-tick scan retargets + // toward the master's closest aggressor for the whole fight. Mobile.Combatant = combatant; Mobile.FocusMob = combatant; Action = ActionType.Combat; @@ -309,6 +310,12 @@ public abstract partial class BaseAI { this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}."); + // Stand down deterministically: a stale Warmode otherwise leaves the return + // pace active or passive by combat history. + Mobile.FocusMob = null; + Mobile.Warmode = false; + Mobile.Combatant = null; + var distance = (int)Mobile.GetDistanceToSqrt(controlMaster); if (distance > 3) @@ -359,67 +366,86 @@ public abstract partial class BaseAI Mobile.ControlTarget = Mobile.ControlMaster; ResumePersistentOrder(); - if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor) + // A resumed Guard engages through its own scan without ever leaving the order; + // only non-guard fallbacks chain the next aggressor through an explicit Attack. + if (Mobile.ControlOrder == OrderType.Guard || + Mobile.FightMode is not (FightMode.Closest or FightMode.Aggressor)) { - FindCombatant(); + return; + } + + var next = FindGuardTarget(); + + if (next != null) + { + Mobile.ControlTarget = next; + Mobile.ControlOrder = OrderType.Attack; + Mobile.Combatant = next; + + this.DebugSayFormatted($"{next.Name} is still hostile! Engaging..."); + + Think(); } } - private void FindCombatant() + /// + /// Selects the aggressor a pet should defend against, preferring whichever is + /// closest to the master (RunUO guard parity — a guarding pet retargets to protect + /// its owner). The current combatant is the baseline and is kept unless a strictly + /// closer aggressor exists. Pure selection: never mutates any order state. + /// + private Mobile FindGuardTarget() { var controlMaster = Mobile.ControlMaster; + var anchor = controlMaster ?? Mobile; + + var current = Mobile.Combatant; + var best = current != controlMaster && IsValidCombatant(current) ? current : null; + var bestDist = best?.GetDistanceToSqrt(anchor) ?? double.MaxValue; foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception)) { - if (!Mobile.CanSee(aggr) || aggr.IsDeadBondedPet || !aggr.Alive) + if (aggr == best || aggr == Mobile || aggr == controlMaster || + aggr.IsDeadBondedPet || !aggr.Alive || + aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster)) { continue; } - var isAttackingPet = aggr.Combatant == Mobile; - var isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster; + var dist = aggr.GetDistanceToSqrt(anchor); - if (isAttackingPet || isAttackingMaster) + if (dist < bestDist && Mobile.CanSee(aggr) && Mobile.InLOS(aggr)) { - if (Mobile.InLOS(aggr)) - { - Mobile.ControlTarget = aggr; - Mobile.ControlOrder = OrderType.Attack; - Mobile.Combatant = aggr; - - var target = isAttackingMaster ? "master" : "me"; - this.DebugSayFormatted($"{aggr.Name} is attacking my {target}! Engaging..."); - - Think(); - return; - } + best = aggr; + bestDist = dist; } } - if (controlMaster?.Aggressors != null) - { - for (var i = 0; i < controlMaster.Aggressors.Count; i++) - { - var aggressor = controlMaster.Aggressors[i].Attacker; + var aggressors = controlMaster?.Aggressors; - if (aggressor?.Deleted != false || !aggressor.Alive || aggressor.IsDeadBondedPet) + if (aggressors != null) + { + for (var i = 0; i < aggressors.Count; i++) + { + var aggressor = aggressors[i].Attacker; + + if (aggressor == best || aggressor?.Deleted != false || !aggressor.Alive || + aggressor.IsDeadBondedPet || !Mobile.InRange(aggressor, Mobile.RangePerception)) { continue; } - if (Mobile.InRange(aggressor, Mobile.RangePerception) && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) + var dist = aggressor.GetDistanceToSqrt(anchor); + + if (dist < bestDist && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) { - Mobile.ControlTarget = aggressor; - Mobile.ControlOrder = OrderType.Attack; - Mobile.Combatant = aggressor; - - this.DebugSayFormatted($"{aggressor.Name} recently attacked my master! Retaliating..."); - - Think(); - return; + best = aggressor; + bestDist = dist; } } } + + return best; } public virtual bool DoOrderRelease() diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 28461e584..25045df1e 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -765,11 +765,15 @@ namespace Server.Mobiles } // Obedience is never slowed by the wild-creature move table; combat - // chases (combatant set) keep it. + // chases (combatant set) keep it. A guarding pet returns to its master + // at the follow sprint pace (RunUO guard parity) with its think cadence + // untouched. if (Controlled && Combatant == null && ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard) { - return _currentSpeed; + return Core.AOS && ControlOrder == OrderType.Guard + ? Math.Min(_currentSpeed, 0.1) + : _currentSpeed; } return _currentSpeed == _activeSpeed ? ActiveMoveSpeed