From ebe730353bae0f259c85b8106172b6efb3eada56 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:13:28 -0700 Subject: [PATCH] feat: independent think/move clocks for creature AI Creatures now run two clocks: the think clock (ActiveSpeed/PassiveSpeed, seconds per decision - unchanged in meaning, storage, and cadence) and a new movement clock (ActiveMoveSpeed/PassiveMoveSpeed, seconds per step, 0 = inherit the matching think value). This lets movement pace be tuned freely - e.g. toward RunUO's TransformMoveDelay pacing - without touching reaction time, spell timing, or acquisition cadence. - BaseCreature: move-speed pair seeded from npc-speeds.json (optional activeMove/passiveMove per bucket), [props-tunable per instance (0 to re-inherit), serialized (v22). SetSpeed keeps its legacy one-clock semantics: it sets the think clock AND clears move overrides, so existing callers cannot half-configure a creature; SetMoveSpeed/ClearMoveSpeed configure movement explicitly. CurrentMoveSpeed resolves by classifying CurrentSpeed - a verbatim active/passive value maps to the matching move value, while bespoke paces written directly (mount boosts, follow sprint) stay fused to both clocks, so external CurrentSpeed writers keep working untouched. - Movement budget: one step consumes a full step of movement-clock budget, accumulative with RunUO's snap-to-now clamp, so long-run pacing averages CurrentMoveSpeed exactly and a stall banks at most one step of catch-up. - AITimer: dual-deadline scheduling. Decisions run at the think cadence exactly as before; while a pursuit or investigation is live, the timer also wakes when the movement budget elapses and advances the step without running decisions. Steps no longer snap to the think grid, so any step delay paces smoothly on the 8ms timer wheel - no ping-pong stutter from incommensurate values. A blocked creature schedules no move wakes and stays think-paced. - Move intent: ApproachTarget/MoveToPoint record their durable goal each en-route tick; arrival, failure, give-up, action changes, or missed renewals clear it. - Badly-hurt slowdown now inflates the step delay only (RunUO parity) and is computed from the base each step. Previously it wrote CurrentSpeed back onto itself, compounding unboundedly while hurt and slowing decisions too. No behavior changes by default: with no bucket or per-instance move values, both clocks carry identical values and creatures pace as before. Co-Authored-By: Claude Fable 5 --- .../Tests/Mobiles/AI/MoveSpeedTests.cs | 133 ++++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/AIMovement.cs | 122 +++++++++++++--- .../UOContent/Mobiles/AI/BaseAI/AITimer.cs | 70 +++++++-- .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 6 +- Projects/UOContent/Mobiles/BaseCreature.cs | 84 ++++++++++- Projects/UOContent/Mobiles/NPCSpeeds.cs | 23 +++ .../modernuo-content-patterns.md | 6 + dev-docs/content-patterns.md | 29 ++++ 8 files changed, 443 insertions(+), 30 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs 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