From d73b7ebf378b9b7795e201f31375ec244faa4f78 Mon Sep 17 00:00:00 2001 From: hyper Date: Wed, 22 Jul 2026 18:59:31 +0800 Subject: [PATCH] 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. --- OECS.Tests/EdgeCaseTests.cs | 140 ++++++++++++++++++ OECS.Tests/SerializationTests.cs | 237 +++++++++++++++++++++++++++++++ OECS/Entity.cs | 11 ++ OECS/EntityFormatter.cs | 25 ++++ OECS/WorldSerializer.cs | 19 ++- 5 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 OECS.Tests/SerializationTests.cs create mode 100644 OECS/EntityFormatter.cs diff --git a/OECS.Tests/EdgeCaseTests.cs b/OECS.Tests/EdgeCaseTests.cs index 4b486a6..37cb8d3 100644 --- a/OECS.Tests/EdgeCaseTests.cs +++ b/OECS.Tests/EdgeCaseTests.cs @@ -399,6 +399,111 @@ public class EdgeCaseTests 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 { Target = target }); + world.AddComponent(b, new Relationship { Target = target }); + + // Reorder with duplicate entries — should still work (last occurrence wins). + world.ReorderSources>(target, [b, a, b]); + + var result = world.GetSources>(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 { 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>(target, [extra, a]); + + var result = world.GetSources>(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(); + foreach (var it in world.Select()) + { + seen.Add(it.Entity); + // Remove the singleton during iteration — should be deferred. + world.RemoveSingleton(); + } + + // 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().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().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().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 ────────────────────────────────────────────────────── private sealed class ChangeCollector : IObserver @@ -413,4 +518,39 @@ public class EdgeCaseTests // Phantom types for relationship tests. public struct ChildOf { } 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(_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(); + _ = val.X; + } + } } diff --git a/OECS.Tests/SerializationTests.cs b/OECS.Tests/SerializationTests.cs new file mode 100644 index 0000000..5b4853b --- /dev/null +++ b/OECS.Tests/SerializationTests.cs @@ -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(); + found.Should().NotBe(Entity.Null); + + ref var pos = ref world2.GetComponent(found); + pos.X.Should().Be(1.5f); + pos.Y.Should().Be(2.5f); + + ref var hp = ref world2.GetComponent(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()) + { + 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(); + 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().Should().BeTrue(); + ref var config = ref world2.GetSingleton(); + 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 + { + 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>(); + found.Should().NotBe(Entity.Null); + + ref var rel = ref world2.GetComponent>(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 + { + 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>(); + ref var rel = ref world2.GetComponent>(foundChild); + + // The target entity should still have its component. + world2.HasComponent(rel.Target).Should().BeTrue(); + ref var pos = ref world2.GetComponent(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()) + 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()) + count++; + count.Should().Be(0); + } +} diff --git a/OECS/Entity.cs b/OECS/Entity.cs index dccb5fd..54bfbae 100644 --- a/OECS/Entity.cs +++ b/OECS/Entity.cs @@ -8,6 +8,7 @@ namespace OECS; /// use-after-free bugs when entity IDs are recycled. /// [MessagePackObject(AllowPrivate = true)] +[MessagePackFormatter(typeof(EntityFormatter))] public readonly partial struct Entity : IEquatable { private const uint IdMask = 0x00FF_FFFF; // lower 24 bits @@ -57,6 +58,16 @@ public readonly partial struct Entity : IEquatable /// internal Entity WithVersion(uint version) => new(Id, version); + /// + /// Converts the entity to its raw 32-bit packed representation. + /// + public static explicit operator uint(Entity entity) => entity._value; + + /// + /// Creates an entity from its raw 32-bit packed representation. + /// + public static explicit operator Entity(uint value) => new(value); + public bool Equals(Entity other) => _value == other._value; public override bool Equals(object? obj) => obj is Entity other && Equals(other); diff --git a/OECS/EntityFormatter.cs b/OECS/EntityFormatter.cs new file mode 100644 index 0000000..0690954 --- /dev/null +++ b/OECS/EntityFormatter.cs @@ -0,0 +1,25 @@ +using MessagePack; +using MessagePack.Formatters; + +namespace OECS; + +/// +/// Custom MessagePack formatter for . +/// +/// 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 readonly fields on value types. +/// +public class EntityFormatter : IMessagePackFormatter +{ + 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(); + } +} diff --git a/OECS/WorldSerializer.cs b/OECS/WorldSerializer.cs index ddec987..e6a5441 100644 --- a/OECS/WorldSerializer.cs +++ b/OECS/WorldSerializer.cs @@ -19,6 +19,12 @@ public static class WorldSerializer { var entityComponents = new Dictionary>(); + // 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(); + foreach (var desc in ComponentRegistry.Descriptors) { var set = world.Components.GetSet(desc.Type); @@ -41,9 +47,20 @@ public static class WorldSerializer TypeName = desc.TypeName, 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(); + } + var snapshot = new WorldSnapshot { Entities = entityComponents @@ -116,4 +133,4 @@ public static class WorldSerializer } } } -} \ No newline at end of file +}