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 <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-08-23 13:05:51 -07:00
parent 695efc7d6e
commit 4238980c6d
No known key found for this signature in database
GPG key ID: 7D81DF26D9A5D94A
3 changed files with 119 additions and 47 deletions

View file

@ -197,6 +197,7 @@ public class BaseCreatureSerializationTests : IDisposable
bc.SpeedClass = SpeedLevel.VeryFast; // boss state change 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.125, bc.ActiveSpeed);
Assert.Equal(0.3, bc.PassiveSpeed); Assert.Equal(0.3, bc.PassiveSpeed);
Assert.Equal(0.125, bc.ActiveMoveSpeed); Assert.Equal(0.125, bc.ActiveMoveSpeed);
@ -223,6 +224,41 @@ public class BaseCreatureSerializationTests : IDisposable
Assert.Equal(0.6, copy.PassiveMoveSpeed); 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<Type>()
});
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 private sealed class MobileStub : Mobile
{ {
public MobileStub() => Body = 0xC9; public MobileStub() => Body = 0xC9;

View file

@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using ModernUO.CodeGeneratedEvents;
using ModernUO.Serialization; using ModernUO.Serialization;
using Server.Collections; using Server.Collections;
using Server.ContextMenus; using Server.ContextMenus;
@ -335,8 +334,20 @@ namespace Server.Mobiles
ApplySpeedClass(); 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() private void ApplySpeedClass()
{
if (SpeedEntry == null)
{
return; // Custom (or an unloaded table) has no bucket to apply
}
_applyingSpeedClass = true;
try
{ {
var wasActive = _currentSpeed == _activeSpeed && _currentSpeed != _passiveSpeed; var wasActive = _currentSpeed == _activeSpeed && _currentSpeed != _passiveSpeed;
@ -347,17 +358,44 @@ namespace Server.Mobiles
PassiveSpeed = passiveSpeed; PassiveSpeed = passiveSpeed;
CurrentSpeed = wasActive ? activeSpeed : passiveSpeed; CurrentSpeed = wasActive ? activeSpeed : passiveSpeed;
} }
finally
{
_applyingSpeedClass = false;
}
}
/// <summary>Seconds per AI decision while engaged; see <see cref="ActiveMoveSpeed"/> for movement pace.</summary> /// <summary>Seconds per AI decision while engaged; see <see cref="ActiveMoveSpeed"/> for movement pace.</summary>
[SerializableField(8)] [SerializableField(8, fieldChanged: nameof(OnSpeedTuned))]
[SaveFlag(nameof(ShouldSerializeActiveSpeed), nameof(ActiveSpeedDefaultValue))] [SaveFlag(nameof(ShouldSerializeSpeeds), nameof(ActiveSpeedDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster)] [SerializedCommandProperty(AccessLevel.GameMaster)]
private double _activeSpeed; 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 _); if (_speedClass == SpeedLevel.Custom)
return _activeSpeed != activeSpeed; {
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() private double ActiveSpeedDefaultValue()
@ -367,17 +405,11 @@ namespace Server.Mobiles
} }
/// <summary>Seconds per AI decision while idle; see <see cref="PassiveMoveSpeed"/> for movement pace.</summary> /// <summary>Seconds per AI decision while idle; see <see cref="PassiveMoveSpeed"/> for movement pace.</summary>
[SerializableField(9)] [SerializableField(9, fieldChanged: nameof(OnSpeedTuned))]
[SaveFlag(nameof(ShouldSerializePassiveSpeed), nameof(PassiveSpeedDefaultValue))] [SaveFlag(nameof(ShouldSerializeSpeeds), nameof(PassiveSpeedDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster)] [SerializedCommandProperty(AccessLevel.GameMaster)]
private double _passiveSpeed; private double _passiveSpeed;
private bool ShouldSerializePassiveSpeed()
{
GetSpeeds(out _, out var passiveSpeed);
return _passiveSpeed != passiveSpeed;
}
private double PassiveSpeedDefaultValue() private double PassiveSpeedDefaultValue()
{ {
GetSpeeds(out _, out var passiveSpeed); GetSpeeds(out _, out var passiveSpeed);
@ -399,8 +431,8 @@ namespace Server.Mobiles
/// Movement clock (seconds per step) while engaged; 0 = inherit /// Movement clock (seconds per step) while engaged; 0 = inherit
/// <see cref="ActiveSpeed"/>. <see cref="CurrentMoveSpeed"/> resolves the pace. /// <see cref="ActiveSpeed"/>. <see cref="CurrentMoveSpeed"/> resolves the pace.
/// </summary> /// </summary>
[SerializableField(11, allowFieldChange: nameof(CoerceMoveSpeed))] [SerializableField(11, allowFieldChange: nameof(CoerceMoveSpeed), fieldChanged: nameof(OnSpeedTuned))]
[SaveFlag(nameof(ShouldSerializeActiveMoveSpeed), nameof(ActiveMoveSpeedDefaultValue))] [SaveFlag(nameof(ShouldSerializeSpeeds), nameof(ActiveMoveSpeedDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster)] [SerializedCommandProperty(AccessLevel.GameMaster)]
private double _activeMoveSpeed; private double _activeMoveSpeed;
@ -408,8 +440,8 @@ namespace Server.Mobiles
/// Movement clock (seconds per step) while idle; 0 = inherit /// Movement clock (seconds per step) while idle; 0 = inherit
/// <see cref="PassiveSpeed"/>. <see cref="CurrentMoveSpeed"/> resolves the pace. /// <see cref="PassiveSpeed"/>. <see cref="CurrentMoveSpeed"/> resolves the pace.
/// </summary> /// </summary>
[SerializableField(12, allowFieldChange: nameof(CoerceMoveSpeed))] [SerializableField(12, allowFieldChange: nameof(CoerceMoveSpeed), fieldChanged: nameof(OnSpeedTuned))]
[SaveFlag(nameof(ShouldSerializePassiveMoveSpeed), nameof(PassiveMoveSpeedDefaultValue))] [SaveFlag(nameof(ShouldSerializeSpeeds), nameof(PassiveMoveSpeedDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster)] [SerializedCommandProperty(AccessLevel.GameMaster)]
private double _passiveMoveSpeed; private double _passiveMoveSpeed;
@ -419,24 +451,12 @@ namespace Server.Mobiles
return true; return true;
} }
private bool ShouldSerializeActiveMoveSpeed()
{
GetMoveSpeeds(out var activeMoveSpeed, out _);
return _activeMoveSpeed != activeMoveSpeed;
}
private double ActiveMoveSpeedDefaultValue() private double ActiveMoveSpeedDefaultValue()
{ {
GetMoveSpeeds(out var activeMoveSpeed, out _); GetMoveSpeeds(out var activeMoveSpeed, out _);
return activeMoveSpeed; return activeMoveSpeed;
} }
private bool ShouldSerializePassiveMoveSpeed()
{
GetMoveSpeeds(out _, out var passiveMoveSpeed);
return _passiveMoveSpeed != passiveMoveSpeed;
}
private double PassiveMoveSpeedDefaultValue() private double PassiveMoveSpeedDefaultValue()
{ {
GetMoveSpeeds(out _, out var passiveMoveSpeed); GetMoveSpeeds(out _, out var passiveMoveSpeed);
@ -853,12 +873,9 @@ namespace Server.Mobiles
FightMode = mode; FightMode = mode;
GetSpeeds(out var activeSpeed, out var passiveSpeed); GetSpeeds(out _activeSpeed, out _passiveSpeed);
GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed);
_currentSpeed = _passiveSpeed;
ActiveSpeed = activeSpeed;
PassiveSpeed = passiveSpeed;
CurrentSpeed = passiveSpeed;
_team = 0; _team = 0;
@ -5121,9 +5138,22 @@ namespace Server.Mobiles
public virtual void GetSpeeds(out double activeSpeed, out double passiveSpeed) public virtual void GetSpeeds(out double activeSpeed, out double passiveSpeed)
{ {
var entry = SpeedEntry ?? throw new InvalidOperationException( 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?" $"{GetType()} has no speed entry - is {"Data/npc-speeds.json"} missing?"
); );
}
activeSpeed = entry.ActiveSpeed; activeSpeed = entry.ActiveSpeed;
passiveSpeed = entry.PassiveSpeed; passiveSpeed = entry.PassiveSpeed;

View file

@ -8,12 +8,13 @@ namespace Server.Mobiles;
public enum SpeedLevel public enum SpeedLevel
{ {
None, None, // resolve by type list, falling back to Medium
VerySlow, VerySlow,
Slow, Slow,
Medium, Medium,
Fast, Fast,
VeryFast VeryFast,
Custom // hand-tuned: no table entry; all four speeds serialize
} }
public static class NPCSpeeds public static class NPCSpeeds
@ -30,6 +31,11 @@ public static class NPCSpeeds
// table is immutable after Configure. // table is immutable after Configure.
public static SpeedClassEntry FindEntry(BaseCreature bc) 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)) && if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) &&
!_speedsByType.TryGetValue(bc.GetType(), out sp)) !_speedsByType.TryGetValue(bc.GetType(), out sp))
{ {