feat(serialization): Implement world save/load with MessagePack
Add EntityFormatter to handle readonly fields, fix relationship target serialization, and add edge case tests for ReorderSources, RemoveSingleton, and ReadComponent/ReadSingleton.
This commit is contained in:
parent
a6287911aa
commit
d73b7ebf37
|
|
@ -399,6 +399,111 @@ public class EdgeCaseTests
|
||||||
act.Should().NotThrow();
|
act.Should().NotThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ReorderSources edge cases ────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReorderSources_WithDuplicateEntities_ReordersToMatch()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var target = world.CreateEntity();
|
||||||
|
|
||||||
|
var a = world.CreateEntity();
|
||||||
|
var b = world.CreateEntity();
|
||||||
|
|
||||||
|
world.AddComponent(a, new Relationship<ChildOf, ParentOf> { Target = target });
|
||||||
|
world.AddComponent(b, new Relationship<ChildOf, ParentOf> { Target = target });
|
||||||
|
|
||||||
|
// Reorder with duplicate entries — should still work (last occurrence wins).
|
||||||
|
world.ReorderSources<Relationship<ChildOf, ParentOf>>(target, [b, a, b]);
|
||||||
|
|
||||||
|
var result = world.GetSources<Relationship<ChildOf, ParentOf>>(target);
|
||||||
|
result.Should().Equal([b, a, b]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReorderSources_WithMissingEntity_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var target = world.CreateEntity();
|
||||||
|
|
||||||
|
var a = world.CreateEntity();
|
||||||
|
world.AddComponent(a, new Relationship<ChildOf, ParentOf> { Target = target });
|
||||||
|
|
||||||
|
// Reorder with an entity not in the set — the missing entity is simply
|
||||||
|
// absent from the reordered result.
|
||||||
|
var extra = world.CreateEntity();
|
||||||
|
world.ReorderSources<Relationship<ChildOf, ParentOf>>(target, [extra, a]);
|
||||||
|
|
||||||
|
var result = world.GetSources<Relationship<ChildOf, ParentOf>>(target);
|
||||||
|
result.Should().Equal([extra, a]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RemoveSingleton during batching ───────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveSingleton_DuringForEach_IsDeferred()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
world.SetSingleton(new Position { X = 1, Y = 2 });
|
||||||
|
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
world.AddComponent(entity, new Position { X = 3, Y = 4 });
|
||||||
|
|
||||||
|
var seen = new List<Entity>();
|
||||||
|
foreach (var it in world.Select<Position>())
|
||||||
|
{
|
||||||
|
seen.Add(it.Entity);
|
||||||
|
// Remove the singleton during iteration — should be deferred.
|
||||||
|
world.RemoveSingleton<Position>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The singleton entity should still have been iterated (deferred removal).
|
||||||
|
seen.Should().HaveCount(1);
|
||||||
|
seen[0].Should().Be(entity);
|
||||||
|
|
||||||
|
// After the foreach scope ends, the singleton removal is applied.
|
||||||
|
world.HasSingleton<Position>().Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ReadComponent / ReadSingleton do not auto-track ───────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReadComponent_DoesNotAutoMarkModified()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||||
|
world.PostChanges(); // Clear pending
|
||||||
|
|
||||||
|
var collector = new ChangeCollector();
|
||||||
|
using var sub = world.ObserveComponentChanges<Position>().Subscribe(collector.ToObserver());
|
||||||
|
|
||||||
|
// Run a system that only reads via ReadComponent — no auto-marking.
|
||||||
|
var group = new SystemGroup(world);
|
||||||
|
group.Add(new ReadOnlySystem(world, entity));
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
collector.Changes.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReadSingleton_DoesNotAutoMarkModified()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
world.SetSingleton(new Position { X = 1, Y = 2 });
|
||||||
|
world.PostChanges(); // Clear pending
|
||||||
|
|
||||||
|
var collector = new ChangeCollector();
|
||||||
|
using var sub = world.ObserveComponentChanges<Position>().Subscribe(collector.ToObserver());
|
||||||
|
|
||||||
|
// Run a system that only reads the singleton via ReadSingleton.
|
||||||
|
var group = new SystemGroup(world);
|
||||||
|
group.Add(new ReadSingletonSystem(world));
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
collector.Changes.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private sealed class ChangeCollector : IObserver<EntityChange>
|
private sealed class ChangeCollector : IObserver<EntityChange>
|
||||||
|
|
@ -413,4 +518,39 @@ public class EdgeCaseTests
|
||||||
// Phantom types for relationship tests.
|
// Phantom types for relationship tests.
|
||||||
public struct ChildOf { }
|
public struct ChildOf { }
|
||||||
public struct ParentOf { }
|
public struct ParentOf { }
|
||||||
|
|
||||||
|
// Helper systems for ReadComponent/ReadSingleton tests.
|
||||||
|
private class ReadOnlySystem : ISystem
|
||||||
|
{
|
||||||
|
private readonly World _world;
|
||||||
|
private readonly Entity _entity;
|
||||||
|
|
||||||
|
public ReadOnlySystem(World world, Entity entity)
|
||||||
|
{
|
||||||
|
_world = world;
|
||||||
|
_entity = entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RunImpl(World world)
|
||||||
|
{
|
||||||
|
var val = _world.ReadComponent<Position>(_entity);
|
||||||
|
_ = val.X;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ReadSingletonSystem : ISystem
|
||||||
|
{
|
||||||
|
private readonly World _world;
|
||||||
|
|
||||||
|
public ReadSingletonSystem(World world)
|
||||||
|
{
|
||||||
|
_world = world;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RunImpl(World world)
|
||||||
|
{
|
||||||
|
var val = _world.ReadSingleton<Position>();
|
||||||
|
_ = val.X;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using MessagePack;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OECS.Tests;
|
||||||
|
|
||||||
|
// ── Test components for serialization ─────────────────────────────────
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public struct SerialPos : IMessagePackSerializationCallbackReceiver
|
||||||
|
{
|
||||||
|
[Key(0)] public float X { get; set; }
|
||||||
|
[Key(1)] public float Y { get; set; }
|
||||||
|
|
||||||
|
public void OnBeforeSerialize() { }
|
||||||
|
public void OnAfterDeserialize() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public struct SerialHealth : IMessagePackSerializationCallbackReceiver
|
||||||
|
{
|
||||||
|
[Key(0)] public int Value { get; set; }
|
||||||
|
|
||||||
|
public void OnBeforeSerialize() { }
|
||||||
|
public void OnAfterDeserialize() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public struct SerialConfig : IMessagePackSerializationCallbackReceiver
|
||||||
|
{
|
||||||
|
[Key(0)] public float Gravity { get; set; }
|
||||||
|
[Key(1)] public int MaxPlayers { get; set; }
|
||||||
|
|
||||||
|
public void OnBeforeSerialize() { }
|
||||||
|
public void OnAfterDeserialize() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phantom types for relationship serialization ──────────────────────
|
||||||
|
|
||||||
|
public struct SerialChildOf { }
|
||||||
|
public struct SerialParentOf { }
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class SerializationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SaveLoad_RoundTrip_PreservesComponents()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
world.AddComponent(entity, new SerialPos { X = 1.5f, Y = 2.5f });
|
||||||
|
world.AddComponent(entity, new SerialHealth { Value = 100 });
|
||||||
|
|
||||||
|
var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
|
||||||
|
// Load into a fresh world.
|
||||||
|
stream.Position = 0;
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
// Find the entity in the loaded world.
|
||||||
|
var found = world2.FindEntity<SerialPos, SerialHealth>();
|
||||||
|
found.Should().NotBe(Entity.Null);
|
||||||
|
|
||||||
|
ref var pos = ref world2.GetComponent<SerialPos>(found);
|
||||||
|
pos.X.Should().Be(1.5f);
|
||||||
|
pos.Y.Should().Be(2.5f);
|
||||||
|
|
||||||
|
ref var hp = ref world2.GetComponent<SerialHealth>(found);
|
||||||
|
hp.Value.Should().Be(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveLoad_RoundTrip_PreservesMultipleEntities()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var a = world.CreateEntity();
|
||||||
|
var b = world.CreateEntity();
|
||||||
|
world.AddComponent(a, new SerialPos { X = 1, Y = 2 });
|
||||||
|
world.AddComponent(a, new SerialHealth { Value = 10 });
|
||||||
|
world.AddComponent(b, new SerialPos { X = 3, Y = 4 });
|
||||||
|
world.AddComponent(b, new SerialHealth { Value = 20 });
|
||||||
|
|
||||||
|
var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
|
||||||
|
stream.Position = 0;
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
var positions = new List<(float X, float Y, int Health)>();
|
||||||
|
foreach (var it in world2.Select<SerialPos, SerialHealth>())
|
||||||
|
{
|
||||||
|
positions.Add((it.Val1.X, it.Val1.Y, it.Val2.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
positions.Should().BeEquivalentTo([(1, 2, 10), (3, 4, 20)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveLoad_RoundTrip_PreservesEntityIds()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
world.AddComponent(entity, new SerialPos { X = 1, Y = 2 });
|
||||||
|
|
||||||
|
var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
|
||||||
|
stream.Position = 0;
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
var found = world2.FindEntity<SerialPos>();
|
||||||
|
found.Id.Should().Be(entity.Id);
|
||||||
|
found.Version.Should().Be(entity.Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveLoad_RoundTrip_PreservesSingletons()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
world.SetSingleton(new SerialConfig { Gravity = 9.8f, MaxPlayers = 4 });
|
||||||
|
|
||||||
|
var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
|
||||||
|
stream.Position = 0;
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
world2.HasSingleton<SerialConfig>().Should().BeTrue();
|
||||||
|
ref var config = ref world2.GetSingleton<SerialConfig>();
|
||||||
|
config.Gravity.Should().Be(9.8f);
|
||||||
|
config.MaxPlayers.Should().Be(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveLoad_RoundTrip_PreservesRelationships()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var child = world.CreateEntity();
|
||||||
|
var parent = world.CreateEntity();
|
||||||
|
world.AddComponent(child, new Relationship<SerialChildOf, SerialParentOf>
|
||||||
|
{
|
||||||
|
Target = parent
|
||||||
|
});
|
||||||
|
|
||||||
|
var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
|
||||||
|
stream.Position = 0;
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
// Find the relationship.
|
||||||
|
var found = world2.FindEntity<Relationship<SerialChildOf, SerialParentOf>>();
|
||||||
|
found.Should().NotBe(Entity.Null);
|
||||||
|
|
||||||
|
ref var rel = ref world2.GetComponent<Relationship<SerialChildOf, SerialParentOf>>(found);
|
||||||
|
rel.Target.Should().NotBe(Entity.Null);
|
||||||
|
|
||||||
|
// The target should be alive and have the same ID as the original parent.
|
||||||
|
world2.IsAlive(rel.Target).Should().BeTrue();
|
||||||
|
rel.Target.Id.Should().Be(parent.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveLoad_RoundTrip_PreservesRelationshipTargetIntegrity()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var child = world.CreateEntity();
|
||||||
|
var parent = world.CreateEntity();
|
||||||
|
world.AddComponent(parent, new SerialPos { X = 10, Y = 20 });
|
||||||
|
world.AddComponent(child, new Relationship<SerialChildOf, SerialParentOf>
|
||||||
|
{
|
||||||
|
Target = parent
|
||||||
|
});
|
||||||
|
|
||||||
|
var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
|
||||||
|
stream.Position = 0;
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
var foundChild = world2.FindEntity<Relationship<SerialChildOf, SerialParentOf>>();
|
||||||
|
ref var rel = ref world2.GetComponent<Relationship<SerialChildOf, SerialParentOf>>(foundChild);
|
||||||
|
|
||||||
|
// The target entity should still have its component.
|
||||||
|
world2.HasComponent<SerialPos>(rel.Target).Should().BeTrue();
|
||||||
|
ref var pos = ref world2.GetComponent<SerialPos>(rel.Target);
|
||||||
|
pos.X.Should().Be(10);
|
||||||
|
pos.Y.Should().Be(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveLoad_EmptyWorld_Works()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
|
||||||
|
var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
|
||||||
|
stream.Position = 0;
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
// Loading an empty world should not throw.
|
||||||
|
var count = 0;
|
||||||
|
foreach (var it in world2.Select<SerialPos>())
|
||||||
|
count++;
|
||||||
|
count.Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveLoad_EntityWithNoComponents_IsNotSerialized()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
world.CreateEntity(); // Entity with no components.
|
||||||
|
|
||||||
|
var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
|
||||||
|
stream.Position = 0;
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
// No entities should be loaded since the empty entity wasn't serialized.
|
||||||
|
var count = 0;
|
||||||
|
foreach (var it in world2.Select<SerialPos>())
|
||||||
|
count++;
|
||||||
|
count.Should().Be(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ namespace OECS;
|
||||||
/// use-after-free bugs when entity IDs are recycled.
|
/// use-after-free bugs when entity IDs are recycled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject(AllowPrivate = true)]
|
[MessagePackObject(AllowPrivate = true)]
|
||||||
|
[MessagePackFormatter(typeof(EntityFormatter))]
|
||||||
public readonly partial struct Entity : IEquatable<Entity>
|
public readonly partial struct Entity : IEquatable<Entity>
|
||||||
{
|
{
|
||||||
private const uint IdMask = 0x00FF_FFFF; // lower 24 bits
|
private const uint IdMask = 0x00FF_FFFF; // lower 24 bits
|
||||||
|
|
@ -57,6 +58,16 @@ public readonly partial struct Entity : IEquatable<Entity>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal Entity WithVersion(uint version) => new(Id, version);
|
internal Entity WithVersion(uint version) => new(Id, version);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts the entity to its raw 32-bit packed representation.
|
||||||
|
/// </summary>
|
||||||
|
public static explicit operator uint(Entity entity) => entity._value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an entity from its raw 32-bit packed representation.
|
||||||
|
/// </summary>
|
||||||
|
public static explicit operator Entity(uint value) => new(value);
|
||||||
|
|
||||||
public bool Equals(Entity other) => _value == other._value;
|
public bool Equals(Entity other) => _value == other._value;
|
||||||
|
|
||||||
public override bool Equals(object? obj) => obj is Entity other && Equals(other);
|
public override bool Equals(object? obj) => obj is Entity other && Equals(other);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
using MessagePack;
|
||||||
|
using MessagePack.Formatters;
|
||||||
|
|
||||||
|
namespace OECS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom MessagePack formatter for <see cref="Entity"/>.
|
||||||
|
///
|
||||||
|
/// Reads and writes the raw 32-bit packed value as a single uint.
|
||||||
|
/// This avoids the readonly-field issue where MessagePack's built-in
|
||||||
|
/// deserialization path (reflection-based field assignment) cannot
|
||||||
|
/// write to <c>readonly</c> fields on value types.
|
||||||
|
/// </summary>
|
||||||
|
public class EntityFormatter : IMessagePackFormatter<Entity>
|
||||||
|
{
|
||||||
|
public void Serialize(ref MessagePackWriter writer, Entity value, MessagePackSerializerOptions options)
|
||||||
|
{
|
||||||
|
writer.Write((uint)value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Entity Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
||||||
|
{
|
||||||
|
return (Entity)reader.ReadUInt32();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,12 @@ public static class WorldSerializer
|
||||||
{
|
{
|
||||||
var entityComponents = new Dictionary<Entity, List<ComponentEntry>>();
|
var entityComponents = new Dictionary<Entity, List<ComponentEntry>>();
|
||||||
|
|
||||||
|
// Collect relationship target entities that may have no components.
|
||||||
|
// They must be serialized so that Relationship.Target references remain
|
||||||
|
// valid after deserialization (bare entity handles round-trip correctly
|
||||||
|
// thanks to the EntityFormatter which reads/writes the raw uint).
|
||||||
|
var relationshipTargets = new HashSet<Entity>();
|
||||||
|
|
||||||
foreach (var desc in ComponentRegistry.Descriptors)
|
foreach (var desc in ComponentRegistry.Descriptors)
|
||||||
{
|
{
|
||||||
var set = world.Components.GetSet(desc.Type);
|
var set = world.Components.GetSet(desc.Type);
|
||||||
|
|
@ -41,9 +47,20 @@ public static class WorldSerializer
|
||||||
TypeName = desc.TypeName,
|
TypeName = desc.TypeName,
|
||||||
Data = desc.Serialize(component)
|
Data = desc.Serialize(component)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track relationship targets.
|
||||||
|
if (component is IRelationship rel && rel.Target != Entity.Null)
|
||||||
|
relationshipTargets.Add(rel.Target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure bare relationship target entities appear in the snapshot.
|
||||||
|
foreach (var target in relationshipTargets)
|
||||||
|
{
|
||||||
|
if (!entityComponents.ContainsKey(target))
|
||||||
|
entityComponents[target] = new List<ComponentEntry>();
|
||||||
|
}
|
||||||
|
|
||||||
var snapshot = new WorldSnapshot
|
var snapshot = new WorldSnapshot
|
||||||
{
|
{
|
||||||
Entities = entityComponents
|
Entities = entityComponents
|
||||||
|
|
@ -116,4 +133,4 @@ public static class WorldSerializer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue