From 1ce2efb1ede01f8365b4160fdf4a64563f04e6e8 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:26:42 -0700 Subject: [PATCH 1/6] fix: restore pet obedience pacing and reschedule stale AI wakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pets slowed dramatically after #2591 (issue #2593): the per-step budget grew from half a think interval to the full RunUO-parity move table, and a guarding pet resolves passive (OnCombatantChange clears Warmode whenever the combatant clears), putting Guard/Come at ~1.05s/step for Medium-bucket pets. Order changes and speed-ups also waited out the previously scheduled AITimer wake, because the timer wheel reads Interval only after the next fire. - CurrentMoveSpeed: a controlled pet executing a master's movement order (Come/Follow/Guard, no combatant) paces steps on the think clock — the wild-creature move table no longer slows obedience. Combat chases and herding keep their own pacing. - AITimer: track the pending wake and reschedule (Stop, Delay = remaining, Start) when a speed-up or fresh order moves the earliest deadline up; changes inside a tick still flow through ScheduleNext. New Prod() wakes the AI immediately on player commands, including from a stopped timer (stable claims no longer wait out the random construction stagger). - DoOrderGuard: guard-following routes through WalkMobileRange so it registers a move intent (between-think move wakes) and paths around obstacles instead of bare greedy stepping quantized to the think grid. Closes #2593 Co-Authored-By: Claude Fable 5 --- .../Tests/Mobiles/AI/GuardFollowTests.cs | 48 +++++ .../Tests/Mobiles/AI/PetPacingTests.cs | 193 ++++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/AITimer.cs | 72 ++++++- .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 2 +- .../Mobiles/AI/BaseAI/PetOrderHandlers.cs | 3 +- .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 8 +- Projects/UOContent/Mobiles/BaseCreature.cs | 11 +- 7 files changed, 328 insertions(+), 9 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs new file mode 100644 index 000000000..40b7b9961 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Guard-order following drives real movement primitives (and may pathfind), so it shares +// the pathfinding sequential collection like ApproachTargetTests. +[Collection("Sequential Pathfinding Tests")] +public class GuardFollowTests +{ + [Fact] + public void GuardFollow_StepsTowardMaster_AndRegistersMoveIntent() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map); + + var pet = new PetTestStub(); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); // 6 tiles east, open terrain + pet.SetControlMaster(master); + + var ai = pet.AIObject; + ai.AITimer?.Stop(); // drive manually + pet.ControlOrder = OrderType.Guard; + ai.AITimer?.Stop(); // the order change may restart the timer + + var start = pet.Location; + ai.NextMove = 0; + ai.Obey(); + + var moved = pet.Location != start; + var hasIntent = ai.TryGetMoveWake(out _); + + pet.Delete(); + master.Delete(); + + Assert.True(moved, "a guarding pet beyond guard range must step toward its master"); + // Between-think move wakes require a registered move intent; bare greedy stepping + // quantizes guard-following to the think grid (issue #2593). + Assert.True(hasIntent, "guard-following must register a move intent"); + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs new file mode 100644 index 000000000..ee47a3348 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Pins the pet-obedience pacing policy (issue #2593): a controlled pet executing a +// master's movement order paces its steps on the think clock, not the wild-creature +// move table; combat chases and herding keep their own pacing. +[Collection("Sequential UOContent Tests")] +public class PetPacingTests : IDisposable +{ + private readonly List _created = new(); + + private (PlayerMobile master, PetTestStub pet) Spawn(Point3D masterLoc, Point3D petLoc) + { + var pair = PetTestSetup.SpawnControlledPet(masterLoc, petLoc); + _created.Add(pair.master); + _created.Add(pair.pet); + return pair; + } + + public void Dispose() + { + foreach (var m in _created) + { + m?.Delete(); + } + + _created.Clear(); + } + + [Fact] + public void ObeyingPet_PacesStepsOnThinkClock() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); // wild-creature table pace; must not slow obedience + pet.SetCurrentSpeedToPassive(); + + 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); + } + + // Boundary guard: a pet chasing a combatant keeps the move table. + [Fact] + public void CombatChasingPet_KeepsMoveTable() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var target = new PetTestStub(); + target.MoveToWorld(new Point3D(1003, 1000, 0), Map.Felucca); + _created.Add(target); + + pet.SetMoveSpeed(0.3, 0.9); + pet.ControlOrder = OrderType.Guard; + pet.Combatant = target; + pet.SetCurrentSpeedToActive(); + + Assert.Equal(0.3, pet.CurrentMoveSpeed); + } + + // Boundary guard: herding overrides obedience pacing. + [Fact] + public void HerdedObeyingPet_KeepsHerdingPace() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.45, 0.9); + pet.SetCurrentSpeedToPassive(); + + pet.TargetLocation = new Point2D(1010, 1010); + + Assert.Equal(0.3, pet.CurrentMoveSpeed); // fixed herding pace + } + + private sealed class ThinkProbe : PetTestStub + { + public int Thinks; + + public override void OnThink() + { + Thinks++; + base.OnThink(); + } + } + + private (PlayerMobile master, ThinkProbe pet) SpawnProbe() + { + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + _created.Add(master); + + var pet = new ThinkProbe(); + pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); + pet.SetControlMaster(master); + _created.Add(pet); + + return (master, pet); + } + + // Advances simulated time in 8ms lockstep with the wheel, like the real event loop, + // so wake schedules and Core.TickCount stay in sync. + private static void RunFor(long ms) + { + var deadline = Core._tickCount + ms; + + while (Core._tickCount < deadline) + { + Core._tickCount += 8; + Timer.Slice(Core._tickCount); + } + } + + private static bool RunUntil(Func condition, long maxMs) + { + var deadline = Core._tickCount + maxMs; + + while (Core._tickCount < deadline) + { + if (condition()) + { + return true; + } + + Core._tickCount += 8; + Timer.Slice(Core._tickCount); + } + + return condition(); + } + + // Runs past the random spawn-stagger delay to a known think-tick anchor: returns + // right after a think fires, with the next one a full passive cadence (0.4s) away. + private ThinkProbe SettledProbe(out PlayerMobile master) + { + Core._tickCount = 0; + Timer.Init(0); + + var (m, pet) = SpawnProbe(); + master = m; + pet.ForceIdle = true; // no wandering; pure cadence + pet.ControlOrder = OrderType.Stay; + + var settled = RunUntil(() => pet.Thinks >= 2, 8000); + Assert.True(settled, "the AI must reach a steady think cadence"); + + return pet; + } + + [Fact] + public void OrderChange_WakesStaleThinkTimer() + { + var pet = SettledProbe(out var master); + var thinksBefore = pet.Thinks; + + // Mid-wait on the passive cadence: the next think is ~200ms out. + RunFor(200); + Assert.Equal(thinksBefore, pet.Thinks); + + // The player issues a command; the pet must not wait out the stale wake. + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + + RunFor(80); + Assert.True(pet.Thinks > thinksBefore, "a fresh order must wake the AI promptly"); + } + + [Fact] + public void SpeedUp_ReschedulesPendingWake() + { + var pet = SettledProbe(out _); + var thinksBefore = pet.Thinks; + + // Mid-wait on the passive cadence: the next think is ~200ms out. + RunFor(200); + Assert.Equal(thinksBefore, pet.Thinks); + + // The pet is sped up (e.g. a buff): the next think must move up to the new + // 0.1s cadence instead of waiting out the stale 0.4s deadline. + pet.CurrentSpeed = 0.1; + + RunFor(120); + Assert.True(pet.Thinks > thinksBefore, "a speed-up must reschedule the pending wake"); + } +} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index fe24e5f6d..e86f63044 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -26,6 +26,8 @@ public sealed class AITimer : Timer { private readonly BaseAI _owner; private long _nextThink; + private long _nextWake; // when the pending wheel entry fires; the wheel cannot tell us + private bool _inTick; private int _detectHiddenMinDelay; private int _detectHiddenMaxDelay; @@ -40,8 +42,31 @@ public sealed class AITimer : Timer public void Activate() { _nextThink = Core.TickCount; - Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); + + if (Running) + { + return; + } + + Start(); // keeps the current Delay: the construction stagger for spawn/sector wakes + _nextWake = Core.TickCount + (long)Delay.TotalMilliseconds; + } + + // A fresh command must not wait out the previous cadence: think now. Pets are exempt + // from sector deactivation, so dropping the stagger Delay here cannot bunch sector wakes. + public void Prod() + { + _nextThink = Core.TickCount; + + if (Running) + { + Reschedule(); + return; + } + + Delay = TimeSpan.Zero; Start(); + _nextWake = Core.TickCount + (long)Delay.TotalMilliseconds; } // A speed-up must not wait out a stale, longer think deadline. @@ -52,12 +77,54 @@ public sealed class AITimer : Timer if (candidate - _nextThink < 0) { _nextThink = candidate; + Reschedule(); + } + } + + // Restarts the timer when the new earliest deadline lands before the pending wake. + // The wheel reads Interval only after the next fire, so moving a pending wake earlier + // requires Stop, Delay = remaining, Start. + private void Reschedule() + { + if (_inTick || !Running) + { + return; // ScheduleNext reads the updated deadlines at tick end } - Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); + var now = Core.TickCount; + var deadline = _nextThink; + + if (_owner.TryGetMoveWake(out var nextMove) && nextMove - now > 0 && nextMove - deadline < 0) + { + deadline = nextMove; + } + + if (deadline - _nextWake >= 0) + { + return; // the pending wake is already at or before the new deadline + } + + Stop(); + Delay = TimeSpan.FromMilliseconds(Math.Max(0, deadline - now)); + Start(); + _nextWake = now + (long)Delay.TotalMilliseconds; } protected override void OnTick() + { + _inTick = true; + + try + { + OnTickCore(); + } + finally + { + _inTick = false; + } + } + + private void OnTickCore() { if (ShouldStop()) { @@ -111,6 +178,7 @@ public sealed class AITimer : Timer // The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn. Interval = TimeSpan.FromMilliseconds(delay); + _nextWake = now + (long)Interval.TotalMilliseconds; } private bool ShouldStop() diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index cf64cae17..f86ac2bfe 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -64,7 +64,7 @@ public abstract partial class BaseAI if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active) { - AITimer.Start(); + AITimer.Activate(); } if (Action != ActionType.Wander) diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs index ecb456d2e..0b7bdd0b1 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs @@ -26,7 +26,8 @@ public abstract partial class BaseAI return; } - Activate(); + // A fresh command wakes the AI immediately — never wait out the previous cadence. + AITimer.Prod(); switch (Mobile.ControlOrder) { diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 1b7139a96..176463c50 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -309,13 +309,13 @@ public abstract partial class BaseAI { this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}."); - var guardLocation = controlMaster.Location; - - var distance = (int)Mobile.GetDistanceToSqrt(guardLocation); + var distance = (int)Mobile.GetDistanceToSqrt(controlMaster); if (distance > 3) { - DoMove(Mobile.GetDirectionTo(guardLocation)); + // Through the approach primitive so guard-following registers a move + // intent (between-think move wakes) and paths around obstacles. + WalkMobileRange(controlMaster, 1, true, 1, 3); } else { diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index f64a289f4..28461e584 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -751,7 +751,8 @@ namespace Server.Mobiles /// /// Resolved seconds per step: a verbatim active/passive /// maps to the matching movement value; a bespoke pace stays fused to both clocks. - /// A herded creature is always driven at . + /// A herded creature is always driven at ; a pet + /// executing a master's movement order paces on the think clock. /// [CommandProperty(AccessLevel.GameMaster)] public double CurrentMoveSpeed @@ -763,6 +764,14 @@ namespace Server.Mobiles return HerdingMoveSpeed; } + // Obedience is never slowed by the wild-creature move table; combat + // chases (combatant set) keep it. + if (Controlled && Combatant == null && + ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard) + { + return _currentSpeed; + } + return _currentSpeed == _activeSpeed ? ActiveMoveSpeed : _currentSpeed == _passiveSpeed ? PassiveMoveSpeed : _currentSpeed; 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 2/6] 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 From 2d07b019af8bf8c23d7978b3a7b8f6cae76ed959 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:06:27 -0700 Subject: [PATCH 3/6] docs: document why ControlOrder fires its handler on every assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed whether OnCurrentOrderChanged should be change-guarded: it must not be. A reissued order is a command — "all attack" retargets through ControlTarget with the same order value, "all follow" breaks off combat, "all stay" re-anchors Home — and each depends on the handler running (and, since #2594, on the AI being prodded awake). No code path writes the same value on a hot path; all AI-internal writes are genuine transitions, so same-value fires only occur at player-command rate. Handlers that need change-detection already receive the previous order (ResolveStop uses it). Co-Authored-By: Claude Fable 5 --- Projects/UOContent/Mobiles/BaseCreature.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 25045df1e..4fbdf6561 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -858,6 +858,10 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public Point3D ControlDest { get; set; } + // Deliberately fires on every assignment, not just changes: a reissued order is a + // command ("all attack" retargets via ControlTarget, "all follow" breaks off combat, + // "all stay" re-anchors Home) and must run its handler and wake the AI. Handlers + // needing change-detection compare against the previous order they receive. [CommandProperty(AccessLevel.GameMaster)] public OrderType ControlOrder { From 1a17146e64bf991633a33c57cade96da7f788839 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:12:21 -0700 Subject: [PATCH 4/6] docs: note the spam-safety invariant on AITimer.Prod A prodded think grants reaction, not action: steps are gated by the NextMove budget, swings/casts/abilities/detect-hidden by their own timers, and each command yields at most one think with no compounding. Verified against RunUO, which granted zero extra thinks (change-guarded CurrentSpeed -> random 0-1s timer restart) but also no action-rate advantage, for the same reason. Co-Authored-By: Claude Fable 5 --- Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index e86f63044..2b970296e 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -54,6 +54,9 @@ public sealed class AITimer : Timer // A fresh command must not wait out the previous cadence: think now. Pets are exempt // from sector deactivation, so dropping the stagger Delay here cannot bunch sector wakes. + // Spam-safe: at most one think per command and no compounding, and a think grants no + // action — steps (NextMove budget), swings, casts, abilities and detect-hidden are all + // gated by their own budgets/timers that this never touches. public void Prod() { _nextThink = Core.TickCount; From c411a035e48f3b1ee28e4731f65b435db5dc25e3 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:31:34 -0700 Subject: [PATCH 5/6] docs: document the OnThink excess-call contract and MonsterAbility pacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OnThink is a scheduler pass, not an action: the AI timer fires it at least at the think cadence but also on command prods and reschedules, and players macro commands deliberately. Overrides must gate consequential work on their own tick-count deadlines (or be idempotent); bare per-call random rolls are cosmetics-only. MonsterAbility lives under the same contract: the trigger cooldown is the rate limit, ChanceToTrigger is per-sample jitter, and a zero-cooldown Think/CombatAction ability triggers every sampled think. Order-breaking abilities must own their hold duration — think latency is not a hold. Co-Authored-By: Claude Fable 5 --- .../modernuo-content-patterns.md | 7 +++ dev-docs/content-patterns.md | 55 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index ce7f18acf..cdb3f9e49 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -29,6 +29,13 @@ description: > overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think AND clears move overrides, `SetMoveSpeed()` sets move only -- see `dev-docs/content-patterns.md` § Creature Speeds +8. **`OnThink` overrides must be excess-call tolerant** -- it fires more often than the + think cadence (player commands prod it; speed-ups reschedule it). Gate consequential + work on a tick-count deadline (subtraction form) or make it idempotent; bare per-call + random rolls are cosmetics-only. `MonsterAbility` is under the same contract: the + trigger cooldown is the rate limit, `ChanceToTrigger` is per-sample jitter, and a + zero-cooldown `Think`/`CombatAction` ability triggers every sampled think -- see + `dev-docs/content-patterns.md` § OnThink: the excess-call contract ## New Item Template diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index ec56e1bb3..80d925580 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -283,6 +283,61 @@ 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). +### OnThink: the excess-call contract + +`OnThink()` is a scheduler pass, not an action. The AI timer calls it *at least* at the +think cadence (`CurrentSpeed`), but it can and does fire more often: a player command +wakes the AI immediately (`AITimer.Prod()`), a speed-up reschedules the pending wake, and +players run command macros that drive extra thinks deliberately (order spam is spam-safe +by design — reaction, never action). RunUO had the same property (its timer restarted +with a random delay on every speed change), so this has never been a fixed-rate callback. + +**Every `OnThink` override must be excess-call tolerant.** An extra call must never grant +an extra action: + +- Gate consequential work on its own deadline field, compared in subtraction form + (`Core.TickCount - _nextX >= 0` — see `tick-counts.md`), or make it idempotent. +- Never pace a consequential action with a bare per-call `Utility.RandomDouble()` roll — + its frequency then scales with think rate, which players can influence. Per-call rolls + are acceptable only for pure cosmetics (idle animations, flavor sounds). +- The engine already gates the expensive things: steps (the `NextMove` budget), weapon + swings, spell casts, detect-hidden, and the base `BaseCreature.OnThink` actions (heal, + rummage, aura) all carry their own clocks. Follow that pattern. + +```csharp +private long _nextSpecial; + +public override void OnThink() +{ + base.OnThink(); + + if (Core.TickCount - _nextSpecial >= 0) + { + DoSpecial(); + _nextSpecial = Core.TickCount + 5000; // the real rate limit lives here + } +} +``` + +### MonsterAbility: same contract + +`MonsterAbility.CanTrigger` is sampled once per think for `Think`- and +`CombatAction`-triggered abilities, so abilities live under the same rule: + +- **`MinTriggerCooldown`/`MaxTriggerCooldown` is the real rate limit** — the floor holds + no matter how often thinks fire. Always give a triggered ability a real cooldown. +- **`ChanceToTrigger` is a per-sample roll**: above the cooldown floor, the expected + trigger delay shrinks as think rate rises. Treat the chance as flavor jitter, never as + the rate limiter, and keep cooldowns long relative to the think interval so the jitter + stays negligible (fire breath — chance 0.5, cooldown 30–45s — varies under 1% between + natural and spammed think rates). +- A **zero-cooldown ability records no cooldown at all** and triggers on every sampled + think that passes its chance — only ever correct for passive alteration hooks, never + for `Think`/`CombatAction` triggers. +- An ability that breaks pet orders (fear-style effects) must own its duration explicitly + (a hold state, or a "refuses orders until" deadline checked in the order handlers) — + pets react to re-issued commands immediately, so think latency is not a hold. + --- ## New Spell From 28c5ea2f6837adbbb9bd9a330cd7b299af56cb89 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:43:02 -0700 Subject: [PATCH 6/6] refactor: pet order speeds flow organically through the order handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streamlines the two sprint mechanisms (the CurrentMoveSpeed guard carve-out and DoMoveImpl's follow-master 0.1 write) into one RunUO-parity model: - Order handlers own obedience speed, mirroring RunUO's OnCurrentOrderChanged and DoOrder* writes: issuing a movement order (Come/Follow/Guard/Attack) sets the active think clock, resting orders (Stay/None/Transfer) set passive, and the guard/follow peaceful branches write RunUO's AOS 0.1 sprint (guard's else-branch had the identical `if (Core.AOS) CurrentSpeed = 0.1` as follow). Pre-AOS guard returns run active. - CurrentMoveSpeed reverts to pure herding + classification — the bespoke 0.1 fuses to both clocks through the existing rule, so the sprint needs no special case and the obedience branch is deleted. - DoMoveImpl's per-step speed flip skips obeying pets (their handler owns the pace; per-step passive flips would fight it) and loses its 0.1 write. Combat still re-derives organically via warmode/combatant. Net pacing (Medium bucket): guard/follow AOS returns sprint 0.1 fused (RunUO parity, guard was previously move-clock-only), Come and friend-follow pace at activeMove (0.45, ~= the pre-#2591 feel), and the stale-Warmode active/ passive lottery is gone everywhere. Co-Authored-By: Claude Fable 5 --- .../Tests/Mobiles/AI/GuardFollowTests.cs | 51 ++++++++++++ .../Tests/Mobiles/AI/PetPacingTests.cs | 80 ++++++++++--------- .../UOContent/Mobiles/AI/BaseAI/AIMovement.cs | 34 +++++--- .../Mobiles/AI/BaseAI/PetOrderHandlers.cs | 11 +++ .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 22 ++++- Projects/UOContent/Mobiles/BaseCreature.cs | 18 +---- 6 files changed, 149 insertions(+), 67 deletions(-) diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs index 40b7b9961..476956c88 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs @@ -36,6 +36,8 @@ public class GuardFollowTests var moved = pet.Location != start; var hasIntent = ai.TryGetMoveWake(out _); + var currentSpeed = pet.CurrentSpeed; + var currentMoveSpeed = pet.CurrentMoveSpeed; pet.Delete(); master.Delete(); @@ -44,5 +46,54 @@ public class GuardFollowTests // Between-think move wakes require a registered move intent; bare greedy stepping // quantizes guard-following to the think grid (issue #2593). Assert.True(hasIntent, "guard-following must register a move intent"); + + // RunUO AOS parity: the guard return sprints at the bespoke 0.1, fused to both + // clocks, and the per-step speed flip must not undo it (fixture era is EJ). + Assert.Equal(0.1, currentSpeed); + Assert.Equal(0.1, currentMoveSpeed); + } + + [Fact] + public void GuardReturn_PreAOS_RunsActive() + { + var previous = Core.Expansion; + + try + { + Core.Expansion = Expansion.UOR; + + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map); + + var pet = new PetTestStub(); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + pet.SetControlMaster(master); + + var ai = pet.AIObject; + ai.AITimer?.Stop(); + pet.ControlOrder = OrderType.Guard; + ai.AITimer?.Stop(); + pet.SetCurrentSpeedToPassive(); // a stale passive state must not persist + + ai.NextMove = 0; + ai.Obey(); + + var currentSpeed = pet.CurrentSpeed; + + pet.Delete(); + master.Delete(); + + // No sprint pre-AOS: the return runs organically active. + Assert.Equal(0.2, currentSpeed); + } + finally + { + Core.Expansion = previous; + } } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs index abaae1aab..eac4113ce 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs @@ -32,58 +32,64 @@ public class PetPacingTests : IDisposable _created.Clear(); } + // Issuing an order sets the think clock organically (RunUO OnCurrentOrderChanged + // parity): movement orders run active, resting orders run passive. The move clock + // then resolves through the normal classification — no special-casing. [Fact] - public void ObeyingPet_PacesStepsOnThinkClock() + public void OrderIssue_SetsThinkClock() { var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.SetMoveSpeed(0.3, 0.9); // wild-creature table pace; must not slow obedience - pet.SetCurrentSpeedToPassive(); - - Assert.Equal(OrderType.Come, pet.ControlOrder); - 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 + pet.ControlOrder = OrderType.Come; + Assert.Equal(0.2, pet.CurrentSpeed); + Assert.Equal(0.3, pet.CurrentMoveSpeed); // organic: verbatim active -> activeMove - Assert.Equal(0.1, pet.CurrentMoveSpeed); - Assert.Equal(0.4, pet.CurrentSpeed); // think clock unaffected + pet.ControlOrder = OrderType.Stay; + Assert.Equal(0.4, pet.CurrentSpeed); + Assert.Equal(0.9, pet.CurrentMoveSpeed); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + Assert.Equal(0.2, pet.CurrentSpeed); + + pet.ControlOrder = OrderType.Guard; + Assert.Equal(0.2, pet.CurrentSpeed); } - // Boundary guard: pre-AOS eras have no sprint — guard paces on the think clock. + // RunUO AOS parity: a pet following its master sprints — DoOrderFollow writes the + // bespoke 0.1, which fuses to both clocks through the normal classification. [Fact] - public void GuardReturn_PreAOS_PacesThinkClock() + public void FollowMaster_ObeySprints() { - var previous = Core.Expansion; + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + pet.AIObject.AITimer?.Stop(); - try - { - Core.Expansion = Expansion.UOR; + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; // fixture era is EJ: Core.AOS is true + pet.AIObject.Obey(); - var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.SetMoveSpeed(0.3, 0.9); - pet.SetCurrentSpeedToPassive(); + Assert.Equal(0.1, pet.CurrentSpeed); + Assert.Equal(0.1, pet.CurrentMoveSpeed); + } - pet.ControlOrder = OrderType.Guard; + // A guarding pet at its master's side stays organically active — never the + // stale-warmode passive lottery, and no sprint while there is nowhere to go. + [Fact] + public void GuardAtMastersSide_IsActive() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + pet.AIObject.AITimer?.Stop(); + pet.SetCurrentSpeedToPassive(); - Assert.Equal(0.4, pet.CurrentMoveSpeed); - } - finally - { - Core.Expansion = previous; - } + pet.ControlOrder = OrderType.Guard; + pet.AIObject.Obey(); // nothing to guard against, master adjacent + + Assert.Equal(0.2, pet.CurrentSpeed); + Assert.Equal(0.3, pet.CurrentMoveSpeed); } // Boundary guard: a pet chasing a combatant keeps the move table. diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 1a58ac4d2..bd93b6515 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -118,18 +118,19 @@ public abstract partial class BaseAI if (TryMove(d)) { - // Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget. - if (Core.AOS && IsFollowingMaster()) + // An obeying pet's pace is owned by its order handler (issue sets the think + // clock; guard/follow write the AOS sprint) — the per-step flip re-derives + // speed for wild creatures and combat only, or it would fight those writes. + if (!IsObeyingMoveOrder()) { - Mobile.CurrentSpeed = 0.1; - } - else if (Mobile.Warmode || Mobile.Combatant != null) - { - Mobile.SetCurrentSpeedToActive(); - } - else - { - Mobile.SetCurrentSpeedToPassive(); + if (Mobile.Warmode || Mobile.Combatant != null) + { + Mobile.SetCurrentSpeedToActive(); + } + else + { + Mobile.SetCurrentSpeedToPassive(); + } } ConsumeMoveBudget(); @@ -541,8 +542,7 @@ public abstract partial class BaseAI { nextMove = NextMove; - return (_moveIntentTarget != null || _moveIntentPoint != null) && - Core.TickCount - _moveIntentExpire < 0; + return (_moveIntentTarget != null || _moveIntentPoint != null) && Core.TickCount - _moveIntentExpire < 0; } /// @@ -599,6 +599,14 @@ public abstract partial class BaseAI Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null; + // A pet executing a master's movement order with no combat; its order handler owns + // the speed clocks (mirrors RunUO's OnCurrentOrderChanged/DoOrder* speed writes). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsObeyingMoveOrder() => + Mobile.Controlled && + Mobile.Combatant == null && + Mobile.ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard; + private bool MoveToWithCollisionAvoidance(Mobile target, bool run, int range) { var distance = (int)Mobile.GetDistanceToSqrt(target); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs index 4c8aa10d3..6479b4723 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs @@ -37,6 +37,11 @@ public abstract partial class BaseAI break; } case OrderType.Come: + { + // RunUO parity: movement orders run the active think clock. + Mobile.SetCurrentSpeedToActive(); + break; + } case OrderType.Drop: case OrderType.Friend: case OrderType.Unfriend: @@ -136,6 +141,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToPassive(); // RunUO parity: resting orders run passive } private void HandleTransferOrder() @@ -149,6 +155,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToPassive(); // RunUO parity: resting orders run passive Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; } @@ -163,6 +170,7 @@ public abstract partial class BaseAI _commandIssuer?.RevealingAction(); Mobile.FocusMob = null; Mobile.Warmode = true; + Mobile.SetCurrentSpeedToActive(); // Only a freshly issued command plays the flourish; resuming the persistent // order after an explicit attack must not replay it after every kill. @@ -199,6 +207,7 @@ public abstract partial class BaseAI } Mobile.Warmode = true; + Mobile.SetCurrentSpeedToActive(); Mobile.PlaySound(Mobile.GetAttackSound()); _commandIssuer = null; } @@ -214,6 +223,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToActive(); // RunUO parity: movement orders run active Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; } @@ -229,6 +239,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToPassive(); // RunUO parity: resting orders run passive Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; // Home (the stay anchor) is owned by SetPersistentOrder, not this handler. diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index e9a101b89..0a6ab8a70 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -128,6 +128,13 @@ public abstract partial class BaseAI this.DebugSayFormatted($"I am ordered to follow {Mobile.ControlTarget?.Name}."); + // RunUO AOS parity: a pet sprints after its master (bespoke 0.1 fuses to both + // clocks); other targets keep the active pace the order issue set. + if (Core.AOS && Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null) + { + Mobile.CurrentSpeed = 0.1; + } + if (currentDistance > 1) { WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2); @@ -320,12 +327,23 @@ public abstract partial class BaseAI if (distance > 3) { - // Through the approach primitive so guard-following registers a move - // intent (between-think move wakes) and paths around obstacles. + // RunUO parity: the AOS return sprints (bespoke 0.1 fuses to both + // clocks); earlier eras run active. Through the approach primitive so + // guard-following registers a move intent and paths around obstacles. + if (Core.AOS) + { + Mobile.CurrentSpeed = 0.1; + } + else + { + Mobile.SetCurrentSpeedToActive(); + } + WalkMobileRange(controlMaster, 1, true, 1, 3); } else { + Mobile.SetCurrentSpeedToActive(); // alert at the master's side WalkRandom(3, 1, 1); } } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 4fbdf6561..7f99d6b99 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -750,9 +750,9 @@ namespace Server.Mobiles /// /// Resolved seconds per step: a verbatim active/passive - /// maps to the matching movement value; a bespoke pace stays fused to both clocks. - /// A herded creature is always driven at ; a pet - /// executing a master's movement order paces on the think clock. + /// maps to the matching movement value; a bespoke pace (e.g. the AOS 0.1 sprint + /// pet orders write) stays fused to both clocks. A herded creature is always + /// driven at . /// [CommandProperty(AccessLevel.GameMaster)] public double CurrentMoveSpeed @@ -764,18 +764,6 @@ namespace Server.Mobiles return HerdingMoveSpeed; } - // Obedience is never slowed by the wild-creature move table; combat - // 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 Core.AOS && ControlOrder == OrderType.Guard - ? Math.Min(_currentSpeed, 0.1) - : _currentSpeed; - } - return _currentSpeed == _activeSpeed ? ActiveMoveSpeed : _currentSpeed == _passiveSpeed ? PassiveMoveSpeed : _currentSpeed;