diff --git a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs index 6ccf32ced..07730be7f 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs @@ -85,7 +85,7 @@ public class BaseCreatureSerializationTests : IDisposable Assert.Equal(0.3, copy.ActiveSpeed); Assert.Equal(0.6, copy.PassiveSpeed); Assert.Equal(0.6, copy.CurrentSpeed); - Assert.Equal(0.6, copy.ActiveMoveSpeed); // pulled from the table, not the wire + Assert.Equal(0.6, copy.ActiveMoveSpeed); // class None (no table in tests): restored from the wire Assert.Equal(1.2, copy.PassiveMoveSpeed); Assert.Equal(100, copy.PhysicalDamage); Assert.Equal(BaseCreature.MaxLoyalty, copy.Loyalty); @@ -238,7 +238,7 @@ public class BaseCreatureSerializationTests : IDisposable bc.ActiveSpeed = 0.25; // one tuned value customizes the whole block - Assert.Equal(SpeedLevel.Custom, bc.SpeedClass); // the bucket label never lies + Assert.Equal(SpeedLevel.None, bc.SpeedClass); // the bucket label never lies var writer = new BufferWriter(true); bc.Serialize(writer); @@ -252,7 +252,7 @@ public class BaseCreatureSerializationTests : IDisposable // All four persisted raw - no value is left silently tracking the table. Assert.Equal(buffer.Length, reader.Position); - Assert.Equal(SpeedLevel.Custom, copy.SpeedClass); + Assert.Equal(SpeedLevel.None, copy.SpeedClass); Assert.Equal(0.25, copy.ActiveSpeed); Assert.Equal(0.4, copy.PassiveSpeed); Assert.Equal(0.3, copy.ActiveMoveSpeed); diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index c9662dd78..7c3ff0f66 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -316,7 +316,8 @@ namespace Server.Mobiles private FightMode FightModeDefaultValue() => FightMode.Closest; /// - /// The creature's npc-speeds bucket. Assigning applies the bucket's speeds; a + /// The creature's npc-speeds bucket, resolved at construction. Assigning applies + /// the bucket's speeds; None means custom (its own speeds are authoritative). A /// type's constant bucket belongs in . /// [SerializableField(7, fieldChanged: nameof(OnSpeedClassChange))] @@ -324,9 +325,9 @@ namespace Server.Mobiles [SerializedCommandProperty(AccessLevel.GameMaster)] private SpeedLevel _speedClass; - private bool ShouldSerializeSpeedClass() => _speedClass != DefaultSpeedClass; + private bool ShouldSerializeSpeedClass() => _speedClass != ResolvedDefaultSpeedClass; - private SpeedLevel SpeedClassDefaultValue() => DefaultSpeedClass; + private SpeedLevel SpeedClassDefaultValue() => ResolvedDefaultSpeedClass; private void OnSpeedClassChange(SpeedLevel oldValue, SpeedLevel newValue) { @@ -342,7 +343,7 @@ namespace Server.Mobiles { if (SpeedEntry == null) { - return; // Custom (or an unloaded table) has no bucket to apply + return; // None (custom) or an unloaded table has no bucket to apply } _applyingSpeedClass = true; @@ -375,7 +376,7 @@ namespace Server.Mobiles // cannot exist on the wire. private bool ShouldSerializeSpeeds() { - if (_speedClass == SpeedLevel.Custom) + if (_speedClass == SpeedLevel.None) { return true; } @@ -391,9 +392,9 @@ namespace Server.Mobiles // bucket label must never lie. ApplySpeedClass assigns mid-transition and guards. private void OnSpeedTuned(double oldValue, double newValue) { - if (!_applyingSpeedClass && _speedClass != SpeedLevel.Custom && ShouldSerializeSpeeds()) + if (!_applyingSpeedClass && _speedClass != SpeedLevel.None && ShouldSerializeSpeeds()) { - _speedClass = SpeedLevel.Custom; + _speedClass = SpeedLevel.None; _speedEntry = null; } } @@ -866,7 +867,7 @@ namespace Server.Mobiles _currentAI = ai; _defaultAI = ai; - _speedClass = DefaultSpeedClass; + _speedClass = ResolvedDefaultSpeedClass; RangePerception = iRangePerception; RangeFight = iRangeFight; @@ -877,6 +878,14 @@ namespace Server.Mobiles GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); _currentSpeed = _passiveSpeed; + if (_activeSpeed <= 0 || _passiveSpeed <= 0) + { + // A 0-delay creature spins its AI timer at wheel resolution. + throw new InvalidOperationException( + $"{GetType()} constructed without speeds - is {"Data/npc-speeds.json"} missing?" + ); + } + _team = 0; Debug = false; @@ -908,7 +917,7 @@ namespace Server.Mobiles public BaseCreature(Serial serial) : base(serial) { - _speedClass = DefaultSpeedClass; + _speedClass = ResolvedDefaultSpeedClass; Debug = false; } @@ -2351,6 +2360,14 @@ namespace Server.Mobiles MigrateMoveSpeeds(); } + // Legacy saves carry no bucket; the ctor guessed the type default. If the + // loaded speeds do not conform, the creature is custom. + if (_speedClass != SpeedLevel.None && ShouldSerializeSpeeds()) + { + _speedClass = SpeedLevel.None; + _speedEntry = null; + } + if (version <= 14 && _isParagon && Hue == 0x31) { Hue = Paragon.Hue; // Paragon hue fixed, should now be 0x501. @@ -5134,7 +5151,13 @@ namespace Server.Mobiles // per save (and again on elided loads), so the dictionary walk must not repeat. private NPCSpeeds.SpeedClassEntry _speedEntry; - private NPCSpeeds.SpeedClassEntry SpeedEntry => _speedEntry ??= NPCSpeeds.FindEntry(this); + private NPCSpeeds.SpeedClassEntry SpeedEntry => _speedEntry ??= NPCSpeeds.FindEntry(_speedClass); + + private SpeedLevel? _resolvedDefaultSpeedClass; + + // The bucket a fresh spawn of this type resolves to (construction-time only). + private SpeedLevel ResolvedDefaultSpeedClass => + _resolvedDefaultSpeedClass ??= NPCSpeeds.ResolveDefaultLevel(this); public virtual void GetSpeeds(out double activeSpeed, out double passiveSpeed) { @@ -5142,7 +5165,7 @@ namespace Server.Mobiles if (entry == null) { - if (_speedClass == SpeedLevel.Custom) + if (_speedClass == SpeedLevel.None) { // A custom creature is its own reference. activeSpeed = _activeSpeed; @@ -5151,7 +5174,7 @@ namespace Server.Mobiles } throw new InvalidOperationException( - $"{GetType()} has no speed entry - is {"Data/npc-speeds.json"} missing?" + $"{GetType()} names bucket {_speedClass} but the table has no entry - is {"Data/npc-speeds.json"} missing?" ); } diff --git a/Projects/UOContent/Mobiles/NPCSpeeds.cs b/Projects/UOContent/Mobiles/NPCSpeeds.cs index b6aacdacb..91a649871 100644 --- a/Projects/UOContent/Mobiles/NPCSpeeds.cs +++ b/Projects/UOContent/Mobiles/NPCSpeeds.cs @@ -8,13 +8,12 @@ namespace Server.Mobiles; public enum SpeedLevel { - None, // resolve by type list, falling back to Medium + None, // no bucket: the creature's own speeds are authoritative (custom) VerySlow, Slow, Medium, Fast, - VeryFast, - Custom // hand-tuned: no table entry; all four speeds serialize + VeryFast } public static class NPCSpeeds @@ -27,24 +26,29 @@ public static class NPCSpeeds public static int MinIdleSeconds { get; private set; } public static int MaxIdleSeconds { get; private set; } - // Null when the table is unloaded (test fixtures). Creatures cache the result — the - // table is immutable after Configure. - public static SpeedClassEntry FindEntry(BaseCreature bc) + // Construction-time resolution of a type's bucket: an explicit DefaultSpeedClass, + // else the table's type list, else Medium so unconfigured creatures never construct + // at 0/0. None only when the table itself is unloaded (test fixtures). + public static SpeedLevel ResolveDefaultLevel(BaseCreature bc) { - if (bc.SpeedClass == SpeedLevel.Custom) + if (bc.DefaultSpeedClass != SpeedLevel.None) { - return null; + return bc.DefaultSpeedClass; } - if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) && - !_speedsByType.TryGetValue(bc.GetType(), out sp)) + if (_speedsByType.TryGetValue(bc.GetType(), out var sp)) { - _speedsByLevel.TryGetValue(SpeedLevel.Medium, out sp); + return sp.Level; } - return sp; + return _speedsByLevel.ContainsKey(SpeedLevel.Medium) ? SpeedLevel.Medium : SpeedLevel.None; } + // Null for None (custom) or an unloaded table. Creatures cache the result — the + // table is immutable after Configure. + public static SpeedClassEntry FindEntry(SpeedLevel level) => + level == SpeedLevel.None ? null : _speedsByLevel.GetValueOrDefault(level); + public static void RegisterSpeed(SpeedClassEntry entry) { _speedsByLevel[entry.Level] = entry;