feat: derive the Running bit from the step pace and fix step-pacing bursts (#2599)

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.
This commit is contained in:
Kamron Batman 2026-08-30 16:48:52 -07:00 committed by GitHub
parent 4420872b22
commit e07416902a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 294 additions and 84 deletions

View file

@ -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();

View file

@ -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;

View file

@ -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<Mobile> _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);
}
}

View file

@ -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();
}

View file

@ -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);

View file

@ -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)
{

View file

@ -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}");

View file

@ -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
{

View file

@ -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.
/// </summary>
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.
/// </summary>
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;

View file

@ -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...");

View file

@ -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
{

View file

@ -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}");

View file

@ -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))

View file

@ -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
{

View file

@ -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;
}

View file

@ -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");

View file

@ -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}");
}

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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

View file

@ -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
{

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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