diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs new file mode 100644 index 000000000..b7c852003 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Pins the CurrentMoveSpeed classification (verbatim active/passive maps to the matching +// move value; bespoke stays fused), SetSpeed's one-clock guarantee, and the v22 tail. +[Collection("Sequential UOContent Tests")] +public class MoveSpeedTests : IDisposable +{ + // Delete spawned stubs so they don't linger in the shared static World. + private readonly List _created = new(); + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private sealed class SpeedStub : BaseCreature + { + public SpeedStub() : base(AIType.AI_Animal) => Body = 0xC9; + + public SpeedStub(Serial serial) : base(serial) => Body = 0xC9; + + // NPCSpeeds isn't configured in the test fixture; provide fixed think speeds. + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } + } + + private SpeedStub NewCreature() + { + var bc = new SpeedStub(); + _created.Add(bc); + return bc; + } + + [Fact] + public void MoveSpeeds_InheritThinkValues_ByDefault() + { + var bc = NewCreature(); + + Assert.Equal(0.3, bc.ActiveMoveSpeed); + Assert.Equal(0.6, bc.PassiveMoveSpeed); + Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed); + } + + [Fact] + public void CurrentMoveSpeed_ResolvesPerMode_WhenOverridden() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + // SetSpeed left the creature passive; the think clock is untouched. + Assert.Equal(0.6, bc.CurrentSpeed); + Assert.Equal(0.9, bc.CurrentMoveSpeed); + + bc.SetCurrentSpeedToActive(); + Assert.Equal(0.3, bc.CurrentSpeed); + Assert.Equal(0.45, bc.CurrentMoveSpeed); + } + + [Fact] + public void CurrentMoveSpeed_BespokePace_StaysFused() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + // Neither think value verbatim, so both clocks run it. + bc.CurrentSpeed = 0.11; + Assert.Equal(0.11, bc.CurrentMoveSpeed); + } + + [Fact] + public void SetSpeed_ClearsMoveOverrides() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + bc.SetSpeed(0.2, 0.4); + + Assert.Equal(0.2, bc.ActiveMoveSpeed); + Assert.Equal(0.4, bc.PassiveMoveSpeed); + } + + [Fact] + public void NonPositiveMoveSpeed_ClearsThatOverride() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + bc.ActiveMoveSpeed = 0; + + Assert.Equal(0.3, bc.ActiveMoveSpeed); // inheriting again + Assert.Equal(0.9, bc.PassiveMoveSpeed); // other override untouched + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void MoveSpeedOverrides_SurviveSerialization(bool overridden) + { + var bc = NewCreature(); + if (overridden) + { + bc.SetMoveSpeed(0.45, 0.9); + } + + var writer = new BufferWriter(true); + bc.Serialize(writer); + + var buffer = new byte[writer.Position]; + writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); + + var copy = new SpeedStub(World.NewMobile); + _created.Add(copy); + var reader = new BufferReader(buffer); + copy.Deserialize(reader); + + // The v22 tail is the last block; exact consumption catches any offset mistake. + Assert.Equal(buffer.Length, reader.Position); + Assert.Equal(overridden ? 0.45 : 0.3, copy.ActiveMoveSpeed); + Assert.Equal(overridden ? 0.9 : 0.6, copy.PassiveMoveSpeed); + } +} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 06282a7cb..ccd1c72b6 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -38,7 +38,18 @@ public abstract partial class BaseAI private bool _approachGaveUp; private Point3D _approachGaveUpGoalLoc; - public static double BadlyHurtMoveDelay(BaseCreature bc) + // --- Move intent (see ContinueMove) ------------------------------------------------ + // Durable movement goal renewed by en-route ApproachTarget/MoveToPoint calls; while + // 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; + + // Inflates a step delay while badly hurt; computed from the passed base so it cannot + // compound across steps. Damage slows steps, never decisions. + public static double BadlyHurtMoveDelay(BaseCreature bc, double delay) { var statMin = Core.HS ? bc.Stam : bc.Hits; var statMax = Core.HS ? bc.StamMax : bc.HitsMax; @@ -46,14 +57,15 @@ public abstract partial class BaseAI if (!bc.IsDeadPet && (bc.ReduceSpeedWithDamage || bc.IsSubdued) && statMax > 0 && statMin < statMax * 0.3) { - var hits = (double)statMin / statMax; + var stat = (double)statMin / statMax; - if (hits < 0.1) { return bc.CurrentSpeed + 0.15; } - if (hits < 0.2) { return bc.CurrentSpeed + 0.1; } - if (hits < 0.3) { return bc.CurrentSpeed + 0.05; } + if (stat < 0.1) { return delay + 0.15; } + if (stat < 0.2) { return delay + 0.1; } + + return delay + 0.05; } - return bc.CurrentSpeed; + return delay; } public bool CanMoveNow(out double delay) @@ -62,12 +74,23 @@ public abstract partial class BaseAI return Core.TickCount - NextMove >= 0; } - // Caps movement at one actual step per AI think tick; pacing itself is the timer's - // cadence. Half a step keeps the budget below the timer interval so a legitimate - // next-tick move is never jitter-throttled. + // 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() { - NextMove = Core.TickCount + Math.Max(50, (int)(Mobile.CurrentSpeed * 500)); + var stepDelay = Mobile.CurrentMoveSpeed; + + if (!(Core.AOS && IsFollowingMaster())) + { + stepDelay = BadlyHurtMoveDelay(Mobile, stepDelay); + } + + NextMove += Math.Max(50, (long)(stepDelay * 1000)); + + if (Core.TickCount - NextMove > 0) + { + NextMove = Core.TickCount; + } } public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves); @@ -95,21 +118,18 @@ public abstract partial class BaseAI if (TryMove(d)) { + // Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget. if (Core.AOS && IsFollowingMaster()) { Mobile.CurrentSpeed = 0.1; } - else if (Mobile.Hits < Mobile.HitsMax * 0.3) - { - Mobile.CurrentSpeed = BadlyHurtMoveDelay(Mobile); - } else if (Mobile.Warmode || Mobile.Combatant != null) { - Mobile.CurrentSpeed = Mobile.ActiveSpeed; + Mobile.SetCurrentSpeedToActive(); } else { - Mobile.CurrentSpeed = Mobile.PassiveSpeed; + Mobile.SetCurrentSpeedToPassive(); } ConsumeMoveBudget(); @@ -319,12 +339,14 @@ public abstract partial class BaseAI { if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false) { + ClearMoveIntent(); return false; } if (Mobile.InRange(target, range)) { ResetApproach(); + ClearMoveIntent(); return true; } @@ -333,12 +355,15 @@ public abstract partial class BaseAI { if (target.Location == _approachGaveUpGoalLoc) { + ClearMoveIntent(); return false; } ResetApproach(); // target moved — try again fresh } + RenewMoveIntent(target, null, run, 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 // auto-turn sidestep can reduce Euclidean distance while moving in the wrong @@ -401,6 +426,7 @@ public abstract partial class BaseAI { if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null) { + ClearMoveIntent(); return false; } @@ -409,16 +435,26 @@ public abstract partial class BaseAI Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl }; } + RenewMoveIntent(null, goal, run, 1); + var couldMove = CanMoveNow(out _) && !IsInBadState(); var locBefore = Mobile.Location; if (Path.Follow(run, 1)) { Path = null; + ClearMoveIntent(); return false; // arrived } - return Mobile.Location != locBefore || !couldMove; + var progressed = Mobile.Location != locBefore || !couldMove; + + if (!progressed) + { + ClearMoveIntent(); + } + + return progressed; } /// @@ -461,10 +497,10 @@ public abstract partial class BaseAI if (++_approachStallTicks >= ApproachGiveUpTicks) { - _approachGaveUp = true; _approachGaveUpGoalLoc = goalLoc; Path = null; + ClearMoveIntent(); } } @@ -480,6 +516,56 @@ public abstract partial class BaseAI _approachGaveUp = false; } + private void RenewMoveIntent(Mobile target, IPoint3D point, bool run, int range) + { + _moveIntentTarget = target; + _moveIntentPoint = point; + _moveIntentRun = run; + _moveIntentRange = range; + + // A live pursuit renews every think tick; unrenewed intent dies on its own. + _moveIntentExpire = Core.TickCount + (long)(Mobile.CurrentSpeed * 2000) + 250; + } + + public void ClearMoveIntent() + { + _moveIntentTarget = null; + _moveIntentPoint = null; + } + + /// + /// True while a durable movement goal is live; is the tick + /// the movement budget elapses. + /// + public bool TryGetMoveWake(out long nextMove) + { + nextMove = NextMove; + + return (_moveIntentTarget != null || _moveIntentPoint != null) && + Core.TickCount - _moveIntentExpire < 0; + } + + /// + /// Advances the current pursuit/investigation by one step on a movement-clock wake; + /// no decisions run. + /// + public void ContinueMove() + { + if (!TryGetMoveWake(out var nextMove) || Core.TickCount - nextMove < 0) + { + return; + } + + if (_moveIntentTarget != null) + { + ApproachTarget(_moveIntentTarget, _moveIntentRun, _moveIntentRange); + } + else + { + MoveToPoint(_moveIntentPoint, _moveIntentRun); + } + } + public virtual bool MoveTo(Mobile m, bool run, int range) { if (Mobile.Deleted || Mobile.DisallowAllMoves || m?.Deleted != false) diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index 3c08d944b..fe24e5f6d 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -17,9 +17,15 @@ using System; namespace Server.Mobiles; +/// +/// Drives an AI on two clocks: decisions at , plus +/// move-only wakes at while a pursuit is live. Each tick +/// schedules the earlier of the two deadlines. +/// public sealed class AITimer : Timer { private readonly BaseAI _owner; + private long _nextThink; private int _detectHiddenMinDelay; private int _detectHiddenMaxDelay; @@ -28,14 +34,29 @@ public sealed class AITimer : Timer { _owner = owner; _owner._nextDetectHidden = Core.TickCount; + _nextThink = Core.TickCount; } public void Activate() { + _nextThink = Core.TickCount; Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); Start(); } + // A speed-up must not wait out a stale, longer think deadline. + public void OnSpeedChanged() + { + var candidate = Core.TickCount + (long)(_owner.Mobile.CurrentSpeed * 1000); + + if (candidate - _nextThink < 0) + { + _nextThink = candidate; + } + + Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); + } + protected override void OnTick() { if (ShouldStop()) @@ -44,23 +65,52 @@ public sealed class AITimer : Timer return; } - _owner.Mobile.OnThink(); - - if (ShouldStop()) + if (Core.TickCount - _nextThink >= 0) { - Stop(); - return; + _owner.Mobile.OnThink(); + + if (ShouldStop()) + { + Stop(); + return; + } + + HandleBardEffects(); + + if (_owner.Mobile.Controlled ? _owner.Obey() : _owner.Think()) + { + HandleDetectHidden(); + } + + // Cadence from the post-decision speed (decisions may flip active/passive). + _nextThink = Core.TickCount + (long)(_owner.Mobile.CurrentSpeed * 1000); + } + else + { + _owner.ContinueMove(); } - Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); - HandleBardEffects(); + ScheduleNext(); + } - if (_owner.Mobile.Controlled ? !_owner.Obey() : !_owner.Think()) + private void ScheduleNext() + { + var now = Core.TickCount; + var delay = _nextThink - now; + + if (_owner.TryGetMoveWake(out var nextMove)) { - return; + var moveDelay = nextMove - now; + + // Only a future budget is a wake — a blocked creature must not spin the timer. + if (moveDelay > 0 && moveDelay < delay) + { + delay = moveDelay; + } } - HandleDetectHidden(); + // The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn. + Interval = TimeSpan.FromMilliseconds(delay); } private bool ShouldStop() diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 4b872ee79..57e96b3fd 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -58,6 +58,7 @@ public abstract partial class BaseAI public BaseAI(BaseCreature m) { Mobile = m; + NextMove = Core.TickCount; AITimer = new AITimer(this); if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active) @@ -295,6 +296,9 @@ public abstract partial class BaseAI public virtual void OnActionChanged() { + // A change of course invalidates between-think movement continuation. + ClearMoveIntent(); + switch (Action) { case ActionType.Wander: @@ -1131,6 +1135,6 @@ public abstract partial class BaseAI public virtual void OnCurrentSpeedChanged() { - AITimer.Interval = TimeSpan.FromSeconds(Mobile.CurrentSpeed); + AITimer.OnSpeedChanged(); } } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index d1a0c7faf..6194241dd 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -265,6 +265,10 @@ namespace Server.Mobiles private double _passiveSpeed; private double _currentSpeed; + // Movement clock (seconds per step); 0 = inherit the matching think value. + private double _activeMoveSpeed; + private double _passiveMoveSpeed; + // Herding - Overrides the AI to force the mob to move to a specific location // Thinking: 0.3s, Movement: 0.6s. private IPoint2D _targetLocation; @@ -342,6 +346,7 @@ namespace Server.Mobiles FightMode = mode; GetSpeeds(out var activeSpeed, out var passiveSpeed); + GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); ActiveSpeed = activeSpeed; PassiveSpeed = passiveSpeed; @@ -673,6 +678,7 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public int RangeHome { get; set; } = 10; + /// Seconds per AI decision while engaged; see for movement pace. [CommandProperty(AccessLevel.GameMaster)] public virtual double ActiveSpeed { @@ -686,6 +692,7 @@ namespace Server.Mobiles } } + /// Seconds per AI decision while idle; see for movement pace. [CommandProperty(AccessLevel.GameMaster)] public virtual double PassiveSpeed { @@ -700,6 +707,22 @@ namespace Server.Mobiles } } + /// Seconds per step while engaged. Inherits ; set 0 to re-inherit. + [CommandProperty(AccessLevel.GameMaster)] + public virtual double ActiveMoveSpeed + { + get => _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed; + set => _activeMoveSpeed = value > 0 ? value : 0; + } + + /// Seconds per step while idle. Inherits ; set 0 to re-inherit. + [CommandProperty(AccessLevel.GameMaster)] + public virtual double PassiveMoveSpeed + { + get => _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed; + set => _passiveMoveSpeed = value > 0 ? value : 0; + } + [CommandProperty(AccessLevel.GameMaster)] public IPoint2D TargetLocation { @@ -725,6 +748,31 @@ 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. + /// + [CommandProperty(AccessLevel.GameMaster)] + public double CurrentMoveSpeed + { + get + { + var current = CurrentSpeed; + + if (current == _activeSpeed) + { + return ActiveMoveSpeed; + } + + if (current == _passiveSpeed) + { + return PassiveMoveSpeed; + } + + return current; + } + } + [CommandProperty(AccessLevel.GameMaster)] public double MoveSpeedMod { get; set; } @@ -1850,7 +1898,7 @@ namespace Server.Mobiles { base.Serialize(writer); - writer.Write(21); // version + writer.Write(22); // version writer.Write((int)m_CurrentAI); writer.Write((int)m_DefaultAI); @@ -1970,6 +2018,10 @@ namespace Server.Mobiles // Version 19 writer.Write(HomeMap); + + // Version 22 (0 = inherit the matching think value) + writer.Write(_activeMoveSpeed); + writer.Write(_passiveMoveSpeed); } public override void Deserialize(IGenericReader reader) @@ -2173,6 +2225,12 @@ namespace Server.Mobiles HomeMap = reader.ReadMap(); } + if (version >= 22) + { + _activeMoveSpeed = reader.ReadDouble(); + _passiveMoveSpeed = reader.ReadDouble(); + } + if (version <= 14 && m_Paragon && Hue == 0x31) { Hue = Paragon.Hue; // Paragon hue fixed, should now be 0x501. @@ -4586,13 +4644,32 @@ namespace Server.Mobiles return false; } + /// + /// Sets the think clock and clears movement overrides (legacy one-clock semantics); + /// use for an independent movement pace. + /// public void SetSpeed(double active, double passive, bool isPassive = true) { ActiveSpeed = active; PassiveSpeed = passive; + ClearMoveSpeed(); CurrentSpeed = isPassive ? PassiveSpeed : ActiveSpeed; } + /// Sets only the movement clock (seconds per step). + public void SetMoveSpeed(double active, double passive) + { + ActiveMoveSpeed = active; + PassiveMoveSpeed = passive; + } + + /// Clears movement overrides; steps pace off the think clock again. + public void ClearMoveSpeed() + { + _activeMoveSpeed = 0; + _passiveMoveSpeed = 0; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetCurrentSpeedToActive() => CurrentSpeed = ActiveSpeed; @@ -4906,6 +4983,11 @@ namespace Server.Mobiles NPCSpeeds.GetSpeeds(this, out activeSpeed, out passiveSpeed); } + public virtual void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) + { + NPCSpeeds.GetMoveSpeeds(this, out activeMoveSpeed, out passiveMoveSpeed); + } + public virtual void DropBackpack() { var backpack = Backpack; diff --git a/Projects/UOContent/Mobiles/NPCSpeeds.cs b/Projects/UOContent/Mobiles/NPCSpeeds.cs index a61ce70c6..8f5e908bb 100644 --- a/Projects/UOContent/Mobiles/NPCSpeeds.cs +++ b/Projects/UOContent/Mobiles/NPCSpeeds.cs @@ -38,6 +38,22 @@ public static class NPCSpeeds passiveSpeed = sp.PassiveSpeed; } + // Move speeds are optional (0 = inherit), so this tolerates a missing entry or table. + public static void GetMoveSpeeds(BaseCreature bc, out double activeMoveSpeed, out double passiveMoveSpeed) + { + if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) && + !_speedsByType.TryGetValue(bc.GetType(), out sp) && + !_speedsByLevel.TryGetValue(SpeedLevel.Medium, out sp)) + { + activeMoveSpeed = 0; + passiveMoveSpeed = 0; + return; + } + + activeMoveSpeed = sp.ActiveMoveSpeed; + passiveMoveSpeed = sp.PassiveMoveSpeed; + } + public static void RegisterSpeed(SpeedClassEntry entry) { _speedsByLevel[entry.Level] = entry; @@ -78,6 +94,13 @@ public static class NPCSpeeds [JsonPropertyName("passive")] public double PassiveSpeed { get; init; } + // Movement clock (seconds per step); absent/0 = inherit the matching think value. + [JsonPropertyName("activeMove")] + public double ActiveMoveSpeed { get; init; } + + [JsonPropertyName("passiveMove")] + public double PassiveMoveSpeed { get; init; } + [JsonPropertyName("types")] public HashSet Types { get; init; } } diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index 12c9515ef..ce7f18acf 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -23,6 +23,12 @@ description: > 4. **Clean up timers and references in `OnDelete()`/`OnAfterDelete()`** 5. **No LINQ** in game logic -- use loops and `PooledRefList` 6. **File placement** matters -- follow the directory conventions below +7. **Creature speeds are delays in seconds, on two clocks** -- think + (`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 ## New Item Template diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 30bb942f3..ec56e1bb3 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -254,6 +254,35 @@ public override int TreasureMapLevel => 3; // Drops treasure map public override double WeaponAbilityChance => 0.4; // Weapon ability chance ``` +### Creature Speeds (think vs move clocks) + +All "speed" values are **delays in seconds** (smaller = faster). A creature runs two clocks: + +- **Think clock** — `ActiveSpeed`/`PassiveSpeed`/`CurrentSpeed`: seconds per AI decision + (combat decisions, target acquisition, spell timing). +- **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed`/`CurrentMoveSpeed`: seconds per + step. Inherits the matching think value until overridden, so a creature configured with + only think speeds behaves as one clock. Any value is legal — steps are scheduled + independently of think ticks, so the two need not divide evenly. + +Speeds normally come from `Distribution/Data/npc-speeds.json` (via `SpeedClass` or type +lists); `activeMove`/`passiveMove` are optional per bucket. Prefer data over code: + +```csharp +public override SpeedLevel SpeedClass => SpeedLevel.Slow; // bucket in npc-speeds.json +``` + +Code-level overrides for special cases: + +```csharp +SetSpeed(0.5, 2.0); // think clock; ALSO clears move overrides (one-clock legacy semantics) +SetMoveSpeed(0.45, 0.9); // move clock only — call after SetSpeed if both are wanted +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). + --- ## New Spell