From e07416902afebad3affbbdc1fbbf8c4a097df4d9 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:48:52 -0700 Subject: [PATCH] feat: derive the Running bit from the step pace and fix step-pacing bursts (#2599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #2594. Fixes jerky creature movement (lich / Fast-bucket melee chases) by choosing the client animation flag from the actual step pace instead of a caller-supplied `run` argument, and fixes three step-pacing defects in the move budget found while verifying it with paired server/client traces. ### Why The `Direction.Running` bit does nothing for creatures server-side (`Mobile.OnMove` reads it only for the player throttle and stealth reveal). Its whole effect is on the client, which animates each step over a fixed time selected by that bit: walk 400 ms / run 200 ms on foot, 200 / 100 ms mounted. ClassicUO queues up to 5 steps and *drops* the sixth, so a creature stepping every 300 ms while flagged as walking backs the queue up until it snaps forward — the observed jerk. The `run` argument never carried the one fact that matters (the step interval). RunUO passed `true` in combat / `false` for pets and gated it on `dist > 5`; #2271 flipped every combat site to `false`; pets passed `currentDistance > 2`. None of that is a coherent signal. ### What **Pace-derived run flag** - `BaseAI.ShouldRun()`: run iff the effective step delay (move clock + badly-hurt inflation) is shorter than `Movement.WalkFootDelay` / `WalkMountDelay` (mounted or flying) — with a continuity rule: an *isolated* step (taken after standing at least a walk interval) goes out as a walk, because the client renders each step alone and a lone run-flagged step is a 200 ms dart. Only a continuing cadence flags run; a true sprinter (pace under the run interpolation) always runs, since a walk-rendered first step would flood the client's 5-step queue. This reproduces RunUO's close-in feel (its `dist > 5` gate) from first principles. - `DoMoveImpl` stamps the bit; it is the single place the flag is set. - `run` removed from `MoveTo`, `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `MoveToWithGroup`, `MoveToWithCollisionAvoidance`, the move intent, and `PathFollower.Follow`. All 35 call sites updated. **API change** for custom scripts — documented in the RunUO migration docs (`09-items-mobiles-creatures.md`, `11-api-reference.md`) and `content-patterns.md` § Creature Speeds. **Move-budget pacing fixes** (each confirmed by UTC-aligned server/client step traces) - A stall no longer banks catch-up steps: the budget's snap-to-now released up to three steps in ~300 ms when a creature resumed chasing after standing beside its target — rendered as a teleport. - Debt accrual removed entirely: a step landing sub-period late (think-grid vs budget misalignment during reactive mirroring) kept the remainder and fired a follow-up ~100 ms later — a dart pair. `ConsumeMoveBudget` now paces every step from when it was actually taken; in continuous pursuit the move-wake lands within wheel resolution of the deadline, so the cost is single-digit-ms drift. - Net effect: a creature can never step faster than its pace, verified across a full chase session (zero sub-pace steps; metronomic 350 ms cadence for a 0.3 s lich). - Test fixture now runs `Movement.Configure()` (the walk delays were 0 in tests). ### Accepted trade-off Animal (LOW group) bodies without a run animation slide on their stand frames when flagged as running. Most are slow enough to stay flagged as walking; the client-side fallback is in ClassicUO/ClassicUO#1930. ### Tests `RunFlagTests`: foot thresholds (0.3 / 0.125 run; 0.4 / 0.45 / 1.05 walk), flying uses the mount threshold, badly-hurt inflation flips a 0.35 s creature back to walk, a real `DoMove` stamps the bit, isolated steps drop to walk (sprinters keep running), a stall restarts the cadence with no banked steps, and a late step earns no quicker follow-up. Full suite: 837 Server + 747 UOContent green. --- .../Fixtures/TestServerInitializer.cs | 1 + .../Tests/Mobiles/AI/ApproachTargetTests.cs | 10 +- .../Tests/Mobiles/AI/RunFlagTests.cs | 162 ++++++++++++++++++ .../Factions/Mobiles/Guards/GuardAI.cs | 4 +- .../UOContent/Engines/Pathing/PathFollower.cs | 6 +- Projects/UOContent/Mobiles/AI/AnimalAI.cs | 2 +- Projects/UOContent/Mobiles/AI/ArcherAI.cs | 2 +- .../Mobiles/AI/BaseAI/AIGroupMovement.cs | 6 +- .../UOContent/Mobiles/AI/BaseAI/AIMovement.cs | 94 +++++----- .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 8 +- .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 6 +- Projects/UOContent/Mobiles/AI/BerserkAI.cs | 2 +- Projects/UOContent/Mobiles/AI/HealerAI.cs | 2 +- Projects/UOContent/Mobiles/AI/MageAI.cs | 10 +- Projects/UOContent/Mobiles/AI/MeleeAI.cs | 2 +- Projects/UOContent/Mobiles/AI/PredatorAI.cs | 4 +- Projects/UOContent/Mobiles/AI/ThiefAI.cs | 2 +- Projects/UOContent/Mobiles/BaseCreature.cs | 2 +- .../Mobiles/Familiars/BaseFamiliar.cs | 2 +- .../Monsters/LBR/Meers/EnragedCreatures.cs | 2 +- .../UOContent/Spells/Ninjitsu/MirrorImage.cs | 5 +- .../migrate-items-mobiles.md | 1 + .../modernuo-content-patterns.md | 5 +- dev-docs/content-patterns.md | 12 ++ .../09-items-mobiles-creatures.md | 23 +++ .../runuo-migration-docs/11-api-reference.md | 3 + 26 files changed, 294 insertions(+), 84 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index a38af9c3d..5e4230b7c 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -103,6 +103,7 @@ internal static class TestServerInitializer // Registers the Accounts entity persistence; without it no test can construct an Account. Server.Accounting.Accounts.Configure(); RaceDefinitions.Configure(); + Server.Movement.Movement.Configure(); MovementImpl.Configure(); PathFollower.Configure(); World.Load(); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs index 923f4aff9..a952252a6 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs @@ -40,7 +40,7 @@ public class ApproachTargetTests for (var i = 0; i < maxTicks; i++) { ai.NextMove = 0; - ai.WalkMobileRange(target, 1, false, 1, 2); + ai.WalkMobileRange(target, 1, 1, 2); if (bc.InRange(target, arriveDist)) { return true; @@ -123,7 +123,7 @@ public class ApproachTargetTests for (var i = 0; i < 200; i++) { ai.NextMove = 0; - ai.MoveTo(target, false, 1); + ai.MoveTo(target, 1); if (bc.InRange(target, 1)) { arrived = true; @@ -154,7 +154,7 @@ public class ApproachTargetTests for (var i = 0; i < 60; i++) { ai.NextMove = 0; - ai.MoveTo(target, true, 1); + ai.MoveTo(target, 1); // Target walks west every other tick for its first several steps, then stops, // so a same-speed chaser eventually closes the gap. @@ -214,7 +214,7 @@ public class ApproachTargetTests for (var i = 0; i < 120; i++) { ai.NextMove = 0; - ai.MoveTo(target, false, 1); + ai.MoveTo(target, 1); } // After giving up, the creature must idle (not oscillate) while the goal is still. @@ -223,7 +223,7 @@ public class ApproachTargetTests for (var i = 0; i < 20; i++) { ai.NextMove = 0; - ai.MoveTo(target, false, 1); + ai.MoveTo(target, 1); if (bc.Location != idleStart) { stayedIdle = false; diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs new file mode 100644 index 000000000..a421aaba2 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs @@ -0,0 +1,162 @@ +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// The Running bit is derived from the step pace: a step shorter than the client's walk +// interpolation (400ms on foot, 200ms mounted/flying) is flagged as a run. +[Collection("Sequential Pathfinding Tests")] +public class RunFlagTests : System.IDisposable +{ + private readonly List _created = new(); + + private PetTestStub Spawn(double activeMove) + { + var pet = new PetTestStub(); + pet.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + pet.AIObject.AITimer?.Stop(); + pet.SetMoveSpeed(activeMove, activeMove * 3); + pet.SetCurrentSpeedToActive(); + pet.LastMoveTime = Core.TickCount; // mid-cadence unless a test says otherwise + _created.Add(pet); + return pet; + } + + public void Dispose() + { + foreach (var m in _created) + { + m?.Delete(); + } + + _created.Clear(); + } + + [Theory] + [InlineData(0.3, true)] + [InlineData(0.125, true)] + [InlineData(0.4, false)] + [InlineData(0.45, false)] + [InlineData(1.05, false)] + public void FootCreature_RunsOnlyWhenFasterThanWalk(double activeMove, bool expected) + { + var pet = Spawn(activeMove); + + Assert.Equal(activeMove, pet.CurrentMoveSpeed); + Assert.Equal(expected, pet.AIObject.ShouldRun()); + } + + [Theory] + [InlineData(0.3, false)] + [InlineData(0.15, true)] + public void FlyingCreature_UsesMountThresholds(double activeMove, bool expected) + { + var pet = Spawn(activeMove); + pet.Flying = true; + + Assert.Equal(expected, pet.AIObject.ShouldRun()); + } + + [Fact] + public void BadlyHurt_SlowsBelowWalk_DropsToWalk() + { + var pet = Spawn(0.35); + Assert.True(pet.AIObject.ShouldRun()); + + // The hurt inflation is on the observed step pace, so the flag follows it. + pet.SetHits(100); + pet.Hits = 5; + pet.SetStam(100); + pet.Stam = 5; + + Assert.False(pet.AIObject.ShouldRun()); + } + + [Theory] + [InlineData(0.3, true)] + [InlineData(0.45, false)] + public void DoMove_StampsRunningBit(double activeMove, bool expected) + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var pet = Spawn(activeMove); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + + var ai = pet.AIObject; + ai.NextMove = 0; + var start = pet.Location; + + Assert.True(ai.DoMove(Direction.West)); + Assert.NotEqual(start, pet.Location); + Assert.Equal(expected, (pet.Direction & Direction.Running) != 0); + } + + // An isolated step (after standing at least a walk interval) renders alone and darts + // if run-flagged, so it walks; continuing cadences and true sprinters keep the flag. + [Fact] + public void IsolatedStep_DropsToWalk() + { + var pet = Spawn(0.3); + pet.LastMoveTime = Core.TickCount - 1000; + + Assert.False(pet.AIObject.ShouldRun()); + } + + [Fact] + public void IsolatedStep_SprinterStillRuns() + { + var pet = Spawn(0.125); + pet.LastMoveTime = Core.TickCount - 1000; + + Assert.True(pet.AIObject.ShouldRun()); + } + + [Fact] + public void StallDoesNotBankCatchUpSteps() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var pet = Spawn(0.3); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + + var ai = pet.AIObject; + ai.NextMove = Core.TickCount - 1000; + + Assert.True(ai.DoMove(Direction.West)); + + // A stall must restart the cadence at full pace: banked catch-up steps + // release as a burst the client renders as a sprint/teleport. + Assert.False(ai.CanMoveNow(out _)); + Assert.True(ai.NextMove - Core.TickCount > 250); + } + + [Fact] + public void LateStepDoesNotEarnAQuickerFollowUp() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var pet = Spawn(0.3); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + + pet.Warmode = true; // keep the active move clock through the step + + var ai = pet.AIObject; + // The step lands 200ms past the budget — under one period, the reactive + // mirroring case (think grid vs budget deadline misalignment). + ai.NextMove = Core.TickCount - 200; + + Assert.True(ai.DoMove(Direction.West)); + + // The debt must not be repaid: a sub-period catch-up step follows ~100ms + // behind and renders as a dart pair beside the player. + Assert.True(ai.NextMove - Core.TickCount > 250); + } +} diff --git a/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs b/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs index 32e8566cb..f0fb2386f 100644 --- a/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs +++ b/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs @@ -359,14 +359,14 @@ namespace Server.Factions { if (m_Mobile.InRange( m, 1 )) RunFrom( m ); - else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo( m, true, 1 )) + else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo(m, 1)) OnFailedMove(); } else {*/ if (!Mobile.InRange(m, Mobile.RangeFight)) { - if (!MoveTo(m, true, 1)) + if (!MoveTo(m, 1)) { OnFailedMove(); } diff --git a/Projects/UOContent/Engines/Pathing/PathFollower.cs b/Projects/UOContent/Engines/Pathing/PathFollower.cs index 3a0a56fa5..04aaf0148 100644 --- a/Projects/UOContent/Engines/Pathing/PathFollower.cs +++ b/Projects/UOContent/Engines/Pathing/PathFollower.cs @@ -83,7 +83,7 @@ public class PathFollower public static bool Check(Point3D loc, Point3D goal, int range) => Utility.InRange(loc, goal, range) && (range > 1 || (loc.Z - goal.Z).Abs() < 16); - public bool Follow(bool run, int range) + public bool Follow(int range) { var goal = GetGoalLocation(); Direction d; @@ -97,13 +97,13 @@ public class PathFollower if (!(Enabled && m_Path.Success)) { - d = m_From.GetDirectionTo(goal, run); + d = m_From.GetDirectionTo(goal); m_From.SetDirection(d); return Move(d) is MoveResult.Success or MoveResult.SuccessAutoTurn && Check(m_From.Location, goal, range); } - d = m_From.GetDirectionTo(m_Next, run); + d = m_From.GetDirectionTo(m_Next); m_From.SetDirection(d); var res = Move(d); diff --git a/Projects/UOContent/Mobiles/AI/AnimalAI.cs b/Projects/UOContent/Mobiles/AI/AnimalAI.cs index a9d410483..e5a1670e3 100644 --- a/Projects/UOContent/Mobiles/AI/AnimalAI.cs +++ b/Projects/UOContent/Mobiles/AI/AnimalAI.cs @@ -38,7 +38,7 @@ public class AnimalAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) { if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) { diff --git a/Projects/UOContent/Mobiles/AI/ArcherAI.cs b/Projects/UOContent/Mobiles/AI/ArcherAI.cs index dedd914c9..23ce0549c 100644 --- a/Projects/UOContent/Mobiles/AI/ArcherAI.cs +++ b/Projects/UOContent/Mobiles/AI/ArcherAI.cs @@ -43,7 +43,7 @@ public class ArcherAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.Weapon.MaxRange)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.Weapon.MaxRange)) { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs index 548fe0d54..4463bf4de 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs @@ -63,7 +63,7 @@ public abstract partial class BaseAI return crowding; } - public static bool MoveToWithGroup(BaseAI ai, Mobile target, bool run, int range) + public static bool MoveToWithGroup(BaseAI ai, Mobile target, int range) { if (Core.TickCount - _lastGroupUpdateTime > 1000) { @@ -79,7 +79,7 @@ public abstract partial class BaseAI if (optimalPosition == Point3D.Zero) { - return ai.MoveToWithCollisionAvoidance(target, run, range); + return ai.MoveToWithCollisionAvoidance(target, range); } _reservedPositions[mobile] = optimalPosition; @@ -99,7 +99,7 @@ public abstract partial class BaseAI } // A blocked or wall-slid step is not progress — route around the obstacle. - return ai.ApproachTarget(target, run, range); + return ai.ApproachTarget(target, range); } finally { diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 5095a4cb4..8b2a16cf2 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -18,6 +18,7 @@ using System.Runtime.CompilerServices; using Server.Collections; using Server.Items; using MoveImpl = Server.Movement.MovementImpl; +using Moves = Server.Movement.Movement; namespace Server.Mobiles; @@ -43,7 +44,6 @@ public abstract partial class BaseAI // live, the AITimer wakes at NextMove between think ticks to advance the step. private Mobile _moveIntentTarget; private IPoint3D _moveIntentPoint; - private bool _moveIntentRun; private int _moveIntentRange; private long _moveIntentExpire; @@ -74,23 +74,42 @@ public abstract partial class BaseAI return Core.TickCount - NextMove >= 0; } - // Accumulative full-step budget: long-run pacing averages CurrentMoveSpeed exactly - // regardless of timer-grid jitter; snap-to-now caps stall catch-up at one step. - private void ConsumeMoveBudget() + // Seconds per step as the client observes it: the move clock plus the hurt inflation. + private double EffectiveStepDelay() { var stepDelay = Mobile.CurrentMoveSpeed; - if (!(Core.AOS && IsFollowingMaster())) + return Core.AOS && IsFollowingMaster() ? stepDelay : BadlyHurtMoveDelay(Mobile, stepDelay); + } + + // The Running bit only selects the client's per-step interpolation (walk 400ms / run + // 200ms on foot, 200/100 mounted). A step shorter than the walk time must run or the + // client falls behind and snaps — but an isolated step (after standing at least a walk + // interval) renders alone and darts if run-flagged, so it goes out as a walk. A true + // sprinter always runs: a walk-rendered first step would flood the client's queue. + public bool ShouldRun() + { + var mounted = Mobile.Mounted || Mobile.Flying; + var walkDelay = mounted ? Moves.WalkMountDelay : Moves.WalkFootDelay; + var pace = EffectiveStepDelay() * 1000; + + if (pace >= walkDelay) { - stepDelay = BadlyHurtMoveDelay(Mobile, stepDelay); + return false; } - NextMove += Math.Max(50, (long)(stepDelay * 1000)); + var runDelay = mounted ? Moves.RunMountDelay : Moves.RunFootDelay; - if (Core.TickCount - NextMove > 0) - { - NextMove = Core.TickCount; - } + return pace < runDelay || Core.TickCount - Mobile.LastMoveTime < walkDelay; + } + + // One step per period, paced from the step just taken — no debt accrual: repaying a + // late step with a quicker follow-up puts two steps ~100ms apart, which renders as a + // dart. In continuous pursuit the move-wake lands within wheel resolution of this + // deadline, so the only cost is single-digit-ms drift per step. + private void ConsumeMoveBudget() + { + NextMove = Core.TickCount + Math.Max(50, (long)(EffectiveStepDelay() * 1000)); } public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves); @@ -108,6 +127,8 @@ public abstract partial class BaseAI return MoveResult.BadState; } + d = (d & Direction.Mask) | (ShouldRun() ? Direction.Running : 0); + if ((Mobile.Direction & Direction.Mask) != (d & Direction.Mask)) { Mobile.Direction = d; @@ -334,7 +355,7 @@ public abstract partial class BaseAI /// best-distance stall counter idles the creature if an in-range goal is genuinely /// unreachable, without ever abandoning a real chase or detour. /// - protected bool ApproachTarget(Mobile target, bool run, int range) + protected bool ApproachTarget(Mobile target, int range) { if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false) { @@ -361,7 +382,7 @@ public abstract partial class BaseAI ResetApproach(); // target moved — try again fresh } - RenewMoveIntent(target, null, run, range); + RenewMoveIntent(target, null, range); // FAST PATH: greedy step toward the target, counted as success ONLY when the move // fully succeeded (not an auto-turn sidestep) and actually got us closer. An @@ -373,7 +394,7 @@ public abstract partial class BaseAI if (Path == null && Mobile.InLOS(target)) { var distBefore = Mobile.GetDistanceToSqrt(target); - var res = DoMoveImpl(Mobile.GetDirectionTo(target, run), true); + var res = DoMoveImpl(Mobile.GetDirectionTo(target), true); if (res == MoveResult.BadState) { @@ -402,7 +423,7 @@ public abstract partial class BaseAI var couldMove = CanMoveNow(out _) && !IsInBadState(); var locBefore = Mobile.Location; - if (Path.Follow(run, range)) + if (Path.Follow(range)) { ResetApproach(); return true; @@ -421,7 +442,7 @@ public abstract partial class BaseAI /// Walks toward a fixed point (e.g. a target's last-known position), pathfinding around /// obstacles. Returns false on arrival or when genuinely unable to make progress. /// - public bool MoveToPoint(IPoint3D goal, bool run) + public bool MoveToPoint(IPoint3D goal) { if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null) { @@ -434,12 +455,12 @@ public abstract partial class BaseAI Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl }; } - RenewMoveIntent(null, goal, run, 1); + RenewMoveIntent(null, goal, 1); var couldMove = CanMoveNow(out _) && !IsInBadState(); var locBefore = Mobile.Location; - if (Path.Follow(run, 1)) + if (Path.Follow(1)) { Path = null; ClearMoveIntent(); @@ -515,11 +536,10 @@ public abstract partial class BaseAI _approachGaveUp = false; } - private void RenewMoveIntent(Mobile target, IPoint3D point, bool run, int range) + private void RenewMoveIntent(Mobile target, IPoint3D point, int range) { _moveIntentTarget = target; _moveIntentPoint = point; - _moveIntentRun = run; _moveIntentRange = range; // A live pursuit renews every think tick; unrenewed intent dies on its own. @@ -556,26 +576,21 @@ public abstract partial class BaseAI if (_moveIntentTarget != null) { - ApproachTarget(_moveIntentTarget, _moveIntentRun, _moveIntentRange); + ApproachTarget(_moveIntentTarget, _moveIntentRange); } else { - MoveToPoint(_moveIntentPoint, _moveIntentRun); + MoveToPoint(_moveIntentPoint); } } - public virtual bool MoveTo(Mobile m, bool run, int range) + public virtual bool MoveTo(Mobile m, int range) { if (Mobile.Deleted || Mobile.DisallowAllMoves || m?.Deleted != false) { return false; } - var distance = (int)Mobile.GetDistanceToSqrt(m); - //TODO Derive the Running bit from CurrentMoveSpeed in DoMoveImpl and drop the run parameter - var distanceThreshold = Core.AOS && IsFollowingMaster() ? 1 : 3; - var shouldRun = distance > distanceThreshold; - if (Mobile.InRange(m, range)) { ResetApproach(); @@ -584,10 +599,10 @@ public abstract partial class BaseAI if (UseGroupMovement(m, range)) { - return MoveToWithGroup(this, m, shouldRun, range); + return MoveToWithGroup(this, m, range); } - return ApproachTarget(m, shouldRun, range); + return ApproachTarget(m, range); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -604,12 +619,8 @@ public abstract partial class BaseAI Mobile.Combatant == null && Mobile.ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard; - private bool MoveToWithCollisionAvoidance(Mobile target, bool run, int range) + private bool MoveToWithCollisionAvoidance(Mobile target, int range) { - var distance = (int)Mobile.GetDistanceToSqrt(target); - - var shouldRun = run && distance > 5; - var direction = Mobile.GetDirectionTo(target); // Wall-slide auto-turns must not count as progress, or a creature pinned on @@ -640,10 +651,10 @@ public abstract partial class BaseAI // Tactical sidesteps exhausted — route around the obstacle via the centralized // approach primitive (persistent PathFollower, no oscillation). - return ApproachTarget(target, shouldRun, range); + return ApproachTarget(target, range); } - public virtual bool WalkMobileRange(Mobile m, int iSteps, bool run, int iWantDistMin, int iWantDistMax) + public virtual bool WalkMobileRange(Mobile m, int iSteps, int iWantDistMin, int iWantDistMax) { if (Mobile.Deleted || Mobile.DisallowAllMoves || m == null) { @@ -659,7 +670,7 @@ public abstract partial class BaseAI return true; } - if (!MoveTowardsOrAwayFrom(m, run, iCurrDist, iWantDistMax)) + if (!MoveTowardsOrAwayFrom(m, iCurrDist, iWantDistMax)) { return false; } @@ -670,17 +681,16 @@ public abstract partial class BaseAI return dist >= iWantDistMin && dist <= iWantDistMax; } - // run only sets the client animation; callers gate it on their own distance thresholds. - private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax) + private bool MoveTowardsOrAwayFrom(Mobile m, int iCurrDist, int iWantDistMax) { if (iCurrDist > iWantDistMax) { // Too far: approach via the centralized progress-based primitive. - return ApproachTarget(m, run, iWantDistMax); + return ApproachTarget(m, iWantDistMax); } // Too close: back away. Retreat keeps the simple greedy behavior (out of scope). - if (DoMove(m.GetDirectionTo(Mobile, run), true)) + if (DoMove(m.GetDirectionTo(Mobile), true)) { Path = null; return true; diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index f86ac2bfe..3c2a479e0 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -442,7 +442,7 @@ public abstract partial class BaseAI var master = Mobile.SummonMaster; if (master != null && master.Map == Mobile.Map && master.InRange(Mobile, Mobile.RangePerception)) { - MoveTo(master, false, 1); + MoveTo(master, 1); } } @@ -592,7 +592,7 @@ public abstract partial class BaseAI } _lkpGoal ??= _lkpLocation; - return MoveToPoint(_lkpGoal, false); + return MoveToPoint(_lkpGoal); } private void ClearLastKnown() @@ -644,7 +644,7 @@ public abstract partial class BaseAI _herdGoal = new Point3D(target.X, target.Y, Mobile.Map?.GetAverageZ(target.X, target.Y) ?? Mobile.Z); } - MoveToPoint(_herdGoal, false); + MoveToPoint(_herdGoal); return true; } @@ -798,7 +798,7 @@ public abstract partial class BaseAI { if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true)) { - if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2)) + if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2)) { DebugSay("I backed off to safety. Wandering..."); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 54dd5774f..e6c48850d 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -84,7 +84,7 @@ public abstract partial class BaseAI return true; } - WalkMobileRange(Mobile.ControlMaster, 1, false, 1, 2); + WalkMobileRange(Mobile.ControlMaster, 1, 1, 2); if (Mobile.GetDistanceToSqrt(Mobile.ControlMaster) <= 2) { @@ -136,7 +136,7 @@ public abstract partial class BaseAI if (currentDistance > 1) { - WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2); + WalkMobileRange(Mobile.ControlTarget, 1, 1, 2); } } @@ -333,7 +333,7 @@ public abstract partial class BaseAI Mobile.SetCurrentSpeedToActive(); } - WalkMobileRange(controlMaster, 1, true, 1, 3); + WalkMobileRange(controlMaster, 1, 1, 3); } else { diff --git a/Projects/UOContent/Mobiles/AI/BerserkAI.cs b/Projects/UOContent/Mobiles/AI/BerserkAI.cs index 4663ae8d0..ff00ec91d 100644 --- a/Projects/UOContent/Mobiles/AI/BerserkAI.cs +++ b/Projects/UOContent/Mobiles/AI/BerserkAI.cs @@ -38,7 +38,7 @@ public class BerserkAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/HealerAI.cs b/Projects/UOContent/Mobiles/AI/HealerAI.cs index 2a1127b43..cc54c73c3 100644 --- a/Projects/UOContent/Mobiles/AI/HealerAI.cs +++ b/Projects/UOContent/Mobiles/AI/HealerAI.cs @@ -81,7 +81,7 @@ public class HealerAI : BaseAI return true; } - WalkMobileRange(Mobile.FocusMob, 1, false, 4, 7); + WalkMobileRange(Mobile.FocusMob, 1, 4, 7); // TODO: Should it be able to do this? if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, Mobile.Combatant)) diff --git a/Projects/UOContent/Mobiles/AI/MageAI.cs b/Projects/UOContent/Mobiles/AI/MageAI.cs index 61be59a1f..1ed8cbdda 100644 --- a/Projects/UOContent/Mobiles/AI/MageAI.cs +++ b/Projects/UOContent/Mobiles/AI/MageAI.cs @@ -171,7 +171,7 @@ public class MageAI : BaseAI { if (!SmartAI) { - if (!MoveTo(m, false, Mobile.RangeFight)) + if (!MoveTo(m, Mobile.RangeFight)) { OnFailedMove(); } @@ -185,14 +185,14 @@ public class MageAI : BaseAI { RunFrom(m); } - else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, false, 1)) + else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, 1)) { OnFailedMove(); } } else if (!Mobile.InRange(m, Mobile.RangeFight)) { - if (!MoveTo(m, false, 1)) + if (!MoveTo(m, 1)) { OnFailedMove(); } @@ -701,7 +701,7 @@ public class MageAI : BaseAI { DebugSay("I cannot see my target, moving to regain line of sight"); - if (!MoveTo(c, false, 1)) + if (!MoveTo(c, 1)) { OnFailedMove(); } @@ -1039,7 +1039,7 @@ public class MageAI : BaseAI // target can be invoked. if (!Mobile.InLOS(toTarget)) { - MoveTo(toTarget, true, 1); + MoveTo(toTarget, 1); } else { diff --git a/Projects/UOContent/Mobiles/AI/MeleeAI.cs b/Projects/UOContent/Mobiles/AI/MeleeAI.cs index 544770069..a71d83d5c 100644 --- a/Projects/UOContent/Mobiles/AI/MeleeAI.cs +++ b/Projects/UOContent/Mobiles/AI/MeleeAI.cs @@ -99,7 +99,7 @@ public class MeleeAI : BaseAI private bool AttemptMoveToCombatant(Mobile combatant) { - if (MoveTo(combatant, false, Mobile.RangeFight)) + if (MoveTo(combatant, Mobile.RangeFight)) { return true; } diff --git a/Projects/UOContent/Mobiles/AI/PredatorAI.cs b/Projects/UOContent/Mobiles/AI/PredatorAI.cs index 5e0e01520..b1ea3a638 100644 --- a/Projects/UOContent/Mobiles/AI/PredatorAI.cs +++ b/Projects/UOContent/Mobiles/AI/PredatorAI.cs @@ -41,7 +41,7 @@ public class PredatorAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) { if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) { @@ -70,7 +70,7 @@ public class PredatorAI : BaseAI } else if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true)) { - if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2)) + if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2)) { DebugSay("Well, here I am safe"); diff --git a/Projects/UOContent/Mobiles/AI/ThiefAI.cs b/Projects/UOContent/Mobiles/AI/ThiefAI.cs index 0fa37bbc9..a9209dfbb 100644 --- a/Projects/UOContent/Mobiles/AI/ThiefAI.cs +++ b/Projects/UOContent/Mobiles/AI/ThiefAI.cs @@ -43,7 +43,7 @@ public class ThiefAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) { this.DebugSayFormatted($"I should be closer to {combatant.Name}"); } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 15d9b4c03..05c728cf4 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -2861,7 +2861,7 @@ namespace Server.Mobiles CanBeHarmful(m) && IsEnemy(m)) { Combatant = FocusMob = m; - AIObject?.MoveTo(m, true, 1); + AIObject?.MoveTo(m, 1); DoHarmful(m); } } diff --git a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs index d3b119523..46d939f3b 100644 --- a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs +++ b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs @@ -92,7 +92,7 @@ public abstract partial class BaseFamiliar : BaseCreature Hidden = m_LastHidden = master.Hidden; } - if (AIObject?.WalkMobileRange(master, 5, false, 1, 1) == true) + if (AIObject?.WalkMobileRange(master, 5, 1, 1) == true) { Warmode = master.Warmode; Combatant = master.Combatant; diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs index 91367a5f0..ce4f3526d 100644 --- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs +++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs @@ -108,7 +108,7 @@ namespace Server.Mobiles */ else if (!Combat(this)) { - AIObject?.MoveTo(SummonMaster, false, 5); + AIObject?.MoveTo(SummonMaster, 5); } /* On OSI, if the summon attacks a mobile, the summoner meer also diff --git a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs index 6d85b9d73..63788aa69 100644 --- a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs +++ b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs @@ -238,10 +238,7 @@ namespace Server.Mobiles if (master?.Map == Mobile.Map && master?.InRange(Mobile, Mobile.RangePerception) == true) { - var iCurrDist = (int)Mobile.GetDistanceToSqrt(master); - var bRun = iCurrDist > 5; - - WalkMobileRange(master, 2, bRun, 0, 1); + WalkMobileRange(master, 2, 0, 1); } else { diff --git a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md index 8edf70a72..ce70ad26c 100644 --- a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md +++ b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md @@ -29,6 +29,7 @@ description: > - `BaseCreature(AI, Fight, 10, 1, 0.2, 0.4)` -> `BaseCreature(AI, Fight)` (extra params default) - `Name = "text"` -> `public override string DefaultName => "text";` - Expression-bodied overrides: `public override int Meat { get { return 1; } }` -> `public override int Meat => 1;` +- AI movement calls lose the `run` flag: `MoveTo(m, true, range)` -> `MoveTo(m, range)` (also `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `PathFollower.Follow`); the Running bit is derived from step pace -> `dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md` § AI Movement ## Anti-Patterns - Using `_field--` instead of `Property--` (bypasses MarkDirty tracking) diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index cdb3f9e49..2b6df7d55 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -27,8 +27,9 @@ 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 -- see - `dev-docs/content-patterns.md` § Creature Speeds + AND clears move overrides, `SetMoveSpeed()` sets move only. 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 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 diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 80d925580..3d11efb16 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -283,6 +283,18 @@ 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). +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` / +`WalkMountDelay`) — is flagged as a run, or the client falls behind and snaps. An isolated +step (resuming after at least a walk interval standing) goes out as a walk regardless of +pace — the client renders each step alone, so a run-flagged single step darts — unless the +pace beats the run interpolation (a true sprinter), where a walk-rendered first step would +flood the client's step queue. Movement APIs (`MoveTo`, `WalkMobileRange`, +`ApproachTarget`, `MoveToPoint`) take no run argument; to make a creature run, make it +fast. Creatures step at most once per `CurrentMoveSpeed` period, paced from the step just +taken — a stall never banks catch-up steps, so a resumed chase restarts at full pace. + ### OnThink: the excess-call contract `OnThink()` is a scheduler pass, not an action. The AI timer calls it *at least* at the diff --git a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md index c5029131c..7d4581b20 100644 --- a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md +++ b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md @@ -474,6 +474,29 @@ The extra parameters (RangePerception, RangeFight, ActiveSpeed, PassiveSpeed) ha | `Name = "a creature"` in constructor | `public override string DefaultName => "a creature";` | | `get { return value; }` | `=> value;` expression-bodied | +## AI Movement: No `run` Argument + +RunUO's movement calls took a `run` flag that callers set inconsistently (`true` in +combat, `false` for pets, gated by `dist > 5` inside `MoveTo`). The flag only selects the +client's per-step animation time, so ModernUO derives it from the creature's step pace +(`BaseAI.ShouldRun`) and the parameter is gone: + +```csharp +// RunUO +MoveTo(combatant, true, m_Mobile.RangeFight); +WalkMobileRange(m_Mobile.ControlMaster, 1, false, 0, 1); + +// ModernUO +MoveTo(combatant, Mobile.RangeFight); +WalkMobileRange(Mobile.ControlMaster, 1, 0, 1); +``` + +`ApproachTarget`, `MoveToPoint` and `PathFollower.Follow` lose the argument the same way. +To make a creature run, make it fast (`SetMoveSpeed` / `npc-speeds.json`), not flagged. +An isolated step (after the creature stood for at least a walk interval) goes out as a +walk regardless of pace — only a continuing cadence, or a pace faster than the run +interpolation, flags run. + ## Item Name Changes ```csharp diff --git a/dev-docs/runuo-migration-docs/11-api-reference.md b/dev-docs/runuo-migration-docs/11-api-reference.md index 215dd3b06..358bf13c7 100644 --- a/dev-docs/runuo-migration-docs/11-api-reference.md +++ b/dev-docs/runuo-migration-docs/11-api-reference.md @@ -130,6 +130,9 @@ Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search. | `writer.WriteEncodedInt(value)` | `writer.WriteEncodedInt(value)` | Same | | `InvalidateProperties()` | `InvalidateProperties()` | Same, or use `[InvalidateProperties]` | | `this.MarkDirty()` | `this.MarkDirty()` | NEW — required in custom setters | +| `MoveTo(m, run, range)` | `MoveTo(m, range)` | `run` removed; the Running bit is derived from the step pace (`BaseAI.ShouldRun`) | +| `WalkMobileRange(m, steps, run, min, max)` | `WalkMobileRange(m, steps, min, max)` | Same | +| `PathFollower.Follow(run, range)` | `Follow(range)` | Same | ## Networking