From 4238980c6d7c50d4e5bfdb1c65c648f0daaa2f88 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:05:51 -0700 Subject: [PATCH] feat: treat the four speeds as one block against the bucket Either all four values (active/passive think + move) conform to the creature's speed entry - elided as a set - or the creature is fully custom and all four serialize. Partial conformance cannot exist on the wire, so no value is ever left silently tracking the table beside a hand-tuned sibling. "Fully custom" is a real state: SpeedLevel.Custom (None already means "resolve by type list" for the type-listed species, so it cannot double as the custom marker). Tuning any speed flips the bucket to Custom (the label never lies), Custom resolves no entry and short-circuits the conformance check, a custom creature is its own GetSpeeds reference (paragon snap becomes a natural no-op), and assigning a real bucket un-customs it via ApplySpeedClass. A re-entrancy guard keeps the flip from misreading ApplySpeedClass's half-assigned block, and constructors seed raw fields so DefaultSpeedClass types do not flip at birth. Co-Authored-By: Claude Fable 5 --- .../Mobiles/BaseCreatureSerializationTests.cs | 36 ++++++ Projects/UOContent/Mobiles/BaseCreature.cs | 120 +++++++++++------- Projects/UOContent/Mobiles/NPCSpeeds.cs | 10 +- 3 files changed, 119 insertions(+), 47 deletions(-) diff --git a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs index bf9dd8465..6ccf32ced 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs @@ -197,6 +197,7 @@ public class BaseCreatureSerializationTests : IDisposable bc.SpeedClass = SpeedLevel.VeryFast; // boss state change + Assert.Equal(SpeedLevel.VeryFast, bc.SpeedClass); // conforming assignment holds Assert.Equal(0.125, bc.ActiveSpeed); Assert.Equal(0.3, bc.PassiveSpeed); Assert.Equal(0.125, bc.ActiveMoveSpeed); @@ -223,6 +224,41 @@ public class BaseCreatureSerializationTests : IDisposable Assert.Equal(0.6, copy.PassiveMoveSpeed); } + [Fact] + public void PartialSpeedTuning_MakesTheCreatureFullyCustom() + { + NPCSpeeds.RegisterSpeed(new NPCSpeeds.SpeedClassEntry + { + Level = SpeedLevel.Fast, ActiveSpeed = 0.2, PassiveSpeed = 0.4, + ActiveMoveSpeed = 0.3, PassiveMoveSpeed = 0.9, Types = new HashSet() + }); + + var bc = new BucketStub(); + _created.Add(bc); + + bc.ActiveSpeed = 0.25; // one tuned value customizes the whole block + + Assert.Equal(SpeedLevel.Custom, bc.SpeedClass); // the bucket label never lies + + 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 BucketStub(World.NewMobile); + _created.Add(copy); + var reader = new BufferReader(buffer); + copy.Deserialize(reader); + + // 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(0.25, copy.ActiveSpeed); + Assert.Equal(0.4, copy.PassiveSpeed); + Assert.Equal(0.3, copy.ActiveMoveSpeed); + Assert.Equal(0.9, copy.PassiveMoveSpeed); + } + private sealed class MobileStub : Mobile { public MobileStub() => Body = 0xC9; diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index ab6bf1d94..c9662dd78 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; -using ModernUO.CodeGeneratedEvents; using ModernUO.Serialization; using Server.Collections; using Server.ContextMenus; @@ -335,29 +334,68 @@ namespace Server.Mobiles ApplySpeedClass(); } - // Applies the current bucket's speeds, preserving the active/passive mode. + private bool _applyingSpeedClass; + + // Applies the current bucket's speeds, preserving the active/passive mode. The + // guard keeps OnSpeedTuned from reading the half-assigned block as customization. private void ApplySpeedClass() { - var wasActive = _currentSpeed == _activeSpeed && _currentSpeed != _passiveSpeed; + if (SpeedEntry == null) + { + return; // Custom (or an unloaded table) has no bucket to apply + } - GetSpeeds(out var activeSpeed, out var passiveSpeed); - GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); + _applyingSpeedClass = true; - ActiveSpeed = activeSpeed; - PassiveSpeed = passiveSpeed; - CurrentSpeed = wasActive ? activeSpeed : passiveSpeed; + try + { + var wasActive = _currentSpeed == _activeSpeed && _currentSpeed != _passiveSpeed; + + GetSpeeds(out var activeSpeed, out var passiveSpeed); + GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); + + ActiveSpeed = activeSpeed; + PassiveSpeed = passiveSpeed; + CurrentSpeed = wasActive ? activeSpeed : passiveSpeed; + } + finally + { + _applyingSpeedClass = false; + } } /// Seconds per AI decision while engaged; see for movement pace. - [SerializableField(8)] - [SaveFlag(nameof(ShouldSerializeActiveSpeed), nameof(ActiveSpeedDefaultValue))] + [SerializableField(8, fieldChanged: nameof(OnSpeedTuned))] + [SaveFlag(nameof(ShouldSerializeSpeeds), nameof(ActiveSpeedDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private double _activeSpeed; - private bool ShouldSerializeActiveSpeed() + // The four speeds are one block: either all conform to the bucket (elided as a + // set), or the creature is custom and all four serialize. Partial conformance + // cannot exist on the wire. + private bool ShouldSerializeSpeeds() { - GetSpeeds(out var activeSpeed, out _); - return _activeSpeed != activeSpeed; + if (_speedClass == SpeedLevel.Custom) + { + return true; + } + + GetSpeeds(out var activeSpeed, out var passiveSpeed); + GetMoveSpeeds(out var activeMoveSpeed, out var passiveMoveSpeed); + + return _activeSpeed != activeSpeed || _passiveSpeed != passiveSpeed || + _activeMoveSpeed != activeMoveSpeed || _passiveMoveSpeed != passiveMoveSpeed; + } + + // Tuning any speed away from the bucket makes the creature fully custom - the + // bucket label must never lie. ApplySpeedClass assigns mid-transition and guards. + private void OnSpeedTuned(double oldValue, double newValue) + { + if (!_applyingSpeedClass && _speedClass != SpeedLevel.Custom && ShouldSerializeSpeeds()) + { + _speedClass = SpeedLevel.Custom; + _speedEntry = null; + } } private double ActiveSpeedDefaultValue() @@ -367,17 +405,11 @@ namespace Server.Mobiles } /// Seconds per AI decision while idle; see for movement pace. - [SerializableField(9)] - [SaveFlag(nameof(ShouldSerializePassiveSpeed), nameof(PassiveSpeedDefaultValue))] + [SerializableField(9, fieldChanged: nameof(OnSpeedTuned))] + [SaveFlag(nameof(ShouldSerializeSpeeds), nameof(PassiveSpeedDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private double _passiveSpeed; - private bool ShouldSerializePassiveSpeed() - { - GetSpeeds(out _, out var passiveSpeed); - return _passiveSpeed != passiveSpeed; - } - private double PassiveSpeedDefaultValue() { GetSpeeds(out _, out var passiveSpeed); @@ -399,8 +431,8 @@ namespace Server.Mobiles /// Movement clock (seconds per step) while engaged; 0 = inherit /// . resolves the pace. /// - [SerializableField(11, allowFieldChange: nameof(CoerceMoveSpeed))] - [SaveFlag(nameof(ShouldSerializeActiveMoveSpeed), nameof(ActiveMoveSpeedDefaultValue))] + [SerializableField(11, allowFieldChange: nameof(CoerceMoveSpeed), fieldChanged: nameof(OnSpeedTuned))] + [SaveFlag(nameof(ShouldSerializeSpeeds), nameof(ActiveMoveSpeedDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private double _activeMoveSpeed; @@ -408,8 +440,8 @@ namespace Server.Mobiles /// Movement clock (seconds per step) while idle; 0 = inherit /// . resolves the pace. /// - [SerializableField(12, allowFieldChange: nameof(CoerceMoveSpeed))] - [SaveFlag(nameof(ShouldSerializePassiveMoveSpeed), nameof(PassiveMoveSpeedDefaultValue))] + [SerializableField(12, allowFieldChange: nameof(CoerceMoveSpeed), fieldChanged: nameof(OnSpeedTuned))] + [SaveFlag(nameof(ShouldSerializeSpeeds), nameof(PassiveMoveSpeedDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private double _passiveMoveSpeed; @@ -419,24 +451,12 @@ namespace Server.Mobiles return true; } - private bool ShouldSerializeActiveMoveSpeed() - { - GetMoveSpeeds(out var activeMoveSpeed, out _); - return _activeMoveSpeed != activeMoveSpeed; - } - private double ActiveMoveSpeedDefaultValue() { GetMoveSpeeds(out var activeMoveSpeed, out _); return activeMoveSpeed; } - private bool ShouldSerializePassiveMoveSpeed() - { - GetMoveSpeeds(out _, out var passiveMoveSpeed); - return _passiveMoveSpeed != passiveMoveSpeed; - } - private double PassiveMoveSpeedDefaultValue() { GetMoveSpeeds(out _, out var passiveMoveSpeed); @@ -853,12 +873,9 @@ namespace Server.Mobiles FightMode = mode; - GetSpeeds(out var activeSpeed, out var passiveSpeed); + GetSpeeds(out _activeSpeed, out _passiveSpeed); GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); - - ActiveSpeed = activeSpeed; - PassiveSpeed = passiveSpeed; - CurrentSpeed = passiveSpeed; + _currentSpeed = _passiveSpeed; _team = 0; @@ -5121,9 +5138,22 @@ namespace Server.Mobiles public virtual void GetSpeeds(out double activeSpeed, out double passiveSpeed) { - var entry = SpeedEntry ?? throw new InvalidOperationException( - $"{GetType()} has no speed entry - is {"Data/npc-speeds.json"} missing?" - ); + var entry = SpeedEntry; + + if (entry == null) + { + if (_speedClass == SpeedLevel.Custom) + { + // A custom creature is its own reference. + activeSpeed = _activeSpeed; + passiveSpeed = _passiveSpeed; + return; + } + + throw new InvalidOperationException( + $"{GetType()} has no speed entry - is {"Data/npc-speeds.json"} missing?" + ); + } activeSpeed = entry.ActiveSpeed; passiveSpeed = entry.PassiveSpeed; diff --git a/Projects/UOContent/Mobiles/NPCSpeeds.cs b/Projects/UOContent/Mobiles/NPCSpeeds.cs index 9a8785b5e..b6aacdacb 100644 --- a/Projects/UOContent/Mobiles/NPCSpeeds.cs +++ b/Projects/UOContent/Mobiles/NPCSpeeds.cs @@ -8,12 +8,13 @@ namespace Server.Mobiles; public enum SpeedLevel { - None, + None, // resolve by type list, falling back to Medium VerySlow, Slow, Medium, Fast, - VeryFast + VeryFast, + Custom // hand-tuned: no table entry; all four speeds serialize } public static class NPCSpeeds @@ -30,6 +31,11 @@ public static class NPCSpeeds // table is immutable after Configure. public static SpeedClassEntry FindEntry(BaseCreature bc) { + if (bc.SpeedClass == SpeedLevel.Custom) + { + return null; + } + if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) && !_speedsByType.TryGetValue(bc.GetType(), out sp)) {