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:
parent
4238980c6d
commit
cdcf82cfd8
3 changed files with 54 additions and 27 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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?"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue