ModernUO/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs
Kamron Batman cdcf82cfd8
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>
2026-08-23 13:13:46 -07:00

369 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles;
// BaseCreature's move to the SerializationGenerator (v23) is guarded three ways: the new
// SaveFlag format round-trips both a default and a fully-populated creature with exact
// byte consumption, back-to-back saves are byte-identical (freeze-time stability), and a
// byte-authentic pre-codegen v22 stream (written by a fossilized replica of the old
// Serialize) loads through the legacy path with the table-speed migration applied.
[Collection("Sequential UOContent Tests")]
public class BaseCreatureSerializationTests : IDisposable
{
private readonly List<Mobile> _created = new();
public void Dispose()
{
for (var i = 0; i < _created.Count; i++)
{
_created[i].Delete();
}
}
private class CreatureStub : BaseCreature
{
public CreatureStub() : base(AIType.AI_Melee) => Body = 0xC9;
public CreatureStub(Serial serial) : base(serial) => Body = 0xC9;
// Stands in for the npc-speeds table (unconfigured in the test fixture).
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
{
activeSpeed = 0.3;
passiveSpeed = 0.6;
}
public override void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed)
{
activeMoveSpeed = 0.6;
passiveMoveSpeed = 1.2;
}
}
private CreatureStub NewCreature()
{
var bc = new CreatureStub();
_created.Add(bc);
return bc;
}
private static byte[] Snapshot(Mobile m)
{
var writer = new BufferWriter(true);
m.Serialize(writer);
var buffer = new byte[writer.Position];
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
return buffer;
}
private CreatureStub Load(byte[] buffer)
{
var copy = new CreatureStub(World.NewMobile);
_created.Add(copy);
var reader = new BufferReader(buffer);
copy.Deserialize(reader);
Assert.Equal(buffer.Length, reader.Position); // exact consumption
return copy;
}
[Fact]
public void DefaultCreature_RoundTrips_AndElidesEverything()
{
var bc = NewCreature();
var buffer = Snapshot(bc);
var copy = Load(buffer);
Assert.Equal(AIType.AI_Melee, copy.AI);
Assert.Equal(BaseCreature.DefaultRangePerception, copy.RangePerception);
Assert.Equal(0.3, copy.ActiveSpeed);
Assert.Equal(0.6, copy.PassiveSpeed);
Assert.Equal(0.6, copy.CurrentSpeed);
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);
Assert.Equal(1, copy.ControlSlots);
Assert.NotNull(copy.Owners);
Assert.Empty(copy.Owners);
}
[Fact]
public void BackToBackSaves_AreByteIdentical()
{
var bc = NewCreature();
bc.SetDamage(5, 10);
bc.PhysicalResistanceSeed = 25;
Assert.Equal(Snapshot(bc), Snapshot(bc));
}
[Fact]
public void PopulatedCreature_RoundTrips()
{
var bc = NewCreature();
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
World.AddEntity(master); // ReadEntity resolves the reference through the world table
_created.Add(master);
bc.Tamable = true;
bc.MinTameSkill = 47.1;
bc.SetControlMaster(master);
bc.Owners.Add(master);
bc.ControlOrder = OrderType.Guard;
bc.SetDamage(11, 17);
bc.SetSpeed(0.2, 0.4); // hand-tuned: no longer matches the stub table
bc.SetMoveSpeed(0.25, 0.5);
bc.PhysicalResistanceSeed = 40;
bc.EnergyResistSeed = 15;
bc.FireDamage = 25;
bc.PhysicalDamage = 75;
bc.HitsMaxSeed = 250;
bc.Loyalty = 55;
bc.Home = new Point3D(1000, 1100, 5);
bc.RangeHome = 4;
bc.Team = 3;
bc.IsBonded = true;
bc.BondingBegin = Core.Now;
bc.RemoveIfUntamed = true;
bc.RemoveStep = 2;
bc.CorpseNameOverride = "a test corpse";
var copy = Load(Snapshot(bc));
Assert.True(copy.Controlled);
Assert.Equal(master, copy.ControlMaster);
Assert.Equal(OrderType.Guard, copy.ControlOrder);
Assert.True(copy.Tamable);
Assert.Equal(47.1, copy.MinTameSkill);
Assert.Equal(11, copy.DamageMin);
Assert.Equal(17, copy.DamageMax);
Assert.Equal(0.2, copy.ActiveSpeed);
Assert.Equal(0.4, copy.PassiveSpeed);
Assert.Equal(0.25, copy.ActiveMoveSpeed);
Assert.Equal(0.5, copy.PassiveMoveSpeed);
Assert.Equal(40, copy.PhysicalResistanceSeed);
Assert.Equal(15, copy.EnergyResistSeed);
Assert.Equal(25, copy.FireDamage);
Assert.Equal(75, copy.PhysicalDamage);
Assert.Equal(250, copy.HitsMaxSeed);
Assert.Equal(55, copy.Loyalty);
Assert.Equal(new Point3D(1000, 1100, 5), copy.Home);
Assert.Equal(4, copy.RangeHome);
Assert.Equal(3, copy.Team);
Assert.True(copy.IsBonded);
Assert.Equal(bc.BondingBegin, copy.BondingBegin);
Assert.True(copy.RemoveIfUntamed);
Assert.Equal(2, copy.RemoveStep);
Assert.Equal("a test corpse", copy.CorpseNameOverride);
Assert.Equal(master, copy.LastOwner);
}
private sealed class BucketStub : BaseCreature
{
public BucketStub() : base(AIType.AI_Melee) => Body = 0xC9;
public BucketStub(Serial serial) : base(serial) => Body = 0xC9;
public override SpeedLevel DefaultSpeedClass => SpeedLevel.Fast;
}
[Fact]
public void SpeedClass_Assignment_AppliesBucket_AndRoundTrips()
{
NPCSpeeds.RegisterSpeed(new NPCSpeeds.SpeedClassEntry
{
Level = SpeedLevel.Fast, ActiveSpeed = 0.2, PassiveSpeed = 0.4,
ActiveMoveSpeed = 0.3, PassiveMoveSpeed = 0.9, Types = new HashSet<Type>()
});
NPCSpeeds.RegisterSpeed(new NPCSpeeds.SpeedClassEntry
{
Level = SpeedLevel.VeryFast, ActiveSpeed = 0.125, PassiveSpeed = 0.3,
ActiveMoveSpeed = 0.125, PassiveMoveSpeed = 0.6, Types = new HashSet<Type>()
});
var bc = new BucketStub();
_created.Add(bc);
Assert.Equal(0.2, bc.ActiveSpeed); // seeded from the default bucket
Assert.Equal(0.3, bc.ActiveMoveSpeed);
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);
Assert.Equal(0.6, bc.PassiveMoveSpeed);
Assert.Equal(0.3, bc.CurrentSpeed); // stayed in the passive mode
// The changed bucket persists; the (bucket-matching) speeds elide but restore
// through the new bucket - the consistency the stateful SpeedClass guarantees.
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);
Assert.Equal(buffer.Length, reader.Position);
Assert.Equal(SpeedLevel.VeryFast, copy.SpeedClass);
Assert.Equal(0.125, copy.ActiveSpeed);
Assert.Equal(0.3, copy.PassiveSpeed);
Assert.Equal(0.125, copy.ActiveMoveSpeed);
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.None, 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.None, 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;
}
// Byte-authentic replica of the pre-codegen v22 tail — fossilized so the legacy
// upgrade path stays covered without an old save binary. The full stream is a plain
// Mobile section (identical layout for every Mobile subclass) followed by this tail.
private static void WriteLegacyV22Tail(IGenericWriter writer)
{
writer.Write(22); // version
writer.Write((int)AIType.AI_Melee); // current AI
writer.Write((int)AIType.AI_Melee); // default AI
writer.Write(10); // RangePerception
writer.Write(1); // RangeFight
writer.Write(0); // Team
writer.Write(0.3); // active (matches the stub table)
writer.Write(0.6); // passive
writer.Write(0.6); // current
writer.Write(2000); // Home X
writer.Write(2100); // Home Y
writer.Write(7); // Home Z
writer.Write(6); // RangeHome
writer.Write((int)FightMode.Closest);
writer.Write(false); // controlled
writer.Write((Mobile)null); // control master
writer.Write((Mobile)null); // control target
writer.Write(Point3D.Zero); // control dest
writer.Write((int)OrderType.None);
writer.Write(0.0); // min tame skill
writer.Write(true); // tamable
writer.Write(false); // summoned
writer.Write(2); // control slots
writer.Write(73); // loyalty
writer.Write((Item)null); // waypoint
writer.Write((Mobile)null); // summon master
writer.Write(180); // hits seed
writer.Write(-1); // stam seed
writer.Write(-1); // mana seed
writer.Write(7); // damage min
writer.Write(14); // damage max
writer.Write(30); // phys resist
writer.Write(100); // phys damage
writer.Write(10); // fire resist
writer.Write(0); // fire damage
writer.Write(0); // cold resist
writer.Write(0); // cold damage
writer.Write(0); // poison resist
writer.Write(0); // poison damage
writer.Write(0); // energy resist
writer.Write(0); // energy damage
writer.Write(new List<Mobile>()); // owners
writer.Write(false); // dead pet
writer.Write(false); // bonded
writer.Write(DateTime.MinValue); // bonding begin
writer.Write(DateTime.MinValue); // abandon time
writer.Write(true); // has generated loot
writer.Write(false); // paragon
writer.Write(false); // has friends
writer.Write(false); // remove if untamed
writer.Write(0); // remove step
writer.Write(TimeSpan.Zero); // delete time left
writer.Write((string)null); // corpse name override
writer.Write((Map)null); // home map
writer.Write(0.0); // active move speed (v22)
writer.Write(0.0); // passive move speed (v22)
}
[Fact]
public void LegacyV22Stream_LoadsThroughLegacyPath()
{
// Every serialized BaseCreature starts with the Mobile base section; a plain
// Mobile donor produces a byte-authentic one.
var donor = new MobileStub();
donor.DefaultMobileInit();
_created.Add(donor);
var writer = new BufferWriter(true);
donor.Serialize(writer);
WriteLegacyV22Tail(writer);
var buffer = new byte[writer.Position];
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
var copy = new CreatureStub(World.NewMobile);
_created.Add(copy);
var reader = new BufferReader(buffer);
copy.Deserialize(reader);
Assert.Equal(buffer.Length, reader.Position);
Assert.Equal(10, copy.RangePerception);
Assert.Equal(new Point3D(2000, 2100, 7), copy.Home);
Assert.Equal(6, copy.RangeHome);
Assert.True(copy.Tamable);
Assert.Equal(2, copy.ControlSlots);
Assert.Equal(73, copy.Loyalty);
Assert.Equal(180, copy.HitsMaxSeed);
Assert.Equal(7, copy.DamageMin);
Assert.Equal(14, copy.DamageMax);
Assert.Equal(30, copy.PhysicalResistanceSeed);
Assert.Equal(10, copy.FireResistSeed);
Assert.Equal(0.3, copy.ActiveSpeed);
// v22 wrote explicit zeros for the move overrides ("inherit"), so the resolved
// pace falls back to the think clock.
Assert.Equal(0, copy.ActiveMoveSpeed);
Assert.Equal(0.6, copy.CurrentMoveSpeed); // passive mode, inheriting
}
}