refactor: None means custom; the type list resolves only at construction

The None -> type list -> Medium chain was construction-time defaulting
(so creatures without a bucket or a SetSpeed call never spawn at 0/0),
not a live semantic. The constructor now resolves it once into
_speedClass itself, so at runtime a concrete bucket means table-backed
and None means the creature's own speeds are authoritative - which is
what SpeedLevel.Custom was; it is removed. Runtime entry resolution
collapses to a single level lookup, and the SpeedClass byte still
elides by comparing against the (cached) resolved type default.

Legacy loads guess the type default and demote to None when the loaded
speeds do not conform, so pre-codegen customized creatures (SetSpeed
vendors) come out honestly labeled. A constructor guard keeps a missing
speed table loud instead of spawning 0-delay creatures that spin their
AI timers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-08-23 13:13:46 -07:00
parent 4238980c6d
commit cdcf82cfd8
No known key found for this signature in database
GPG key ID: 7D81DF26D9A5D94A
3 changed files with 54 additions and 27 deletions

View file

@ -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);

View file

@ -316,7 +316,8 @@ namespace Server.Mobiles
private FightMode FightModeDefaultValue() => FightMode.Closest;
/// <summary>
/// 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 <see cref="DefaultSpeedClass"/>.
/// </summary>
[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?"
);
}

View file

@ -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;