feat(oecs): improve serialization and add snapshot tests
Enhance the OECS framework to support more robust component deserialization during world serialization. This includes adding boxed component support and a runtime fallback for component discovery. Additionally, introduces snapshot testing patterns for Blackjack and TicTacToe to allow for manual verification of world state and reactivity logs.
This commit is contained in:
@@ -28,15 +28,23 @@ public sealed class ComponentDescriptor
|
||||
/// </summary>
|
||||
public Action<World, Entity, byte[]> DeserializeAndAdd { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a MessagePack byte array to a boxed component object.
|
||||
/// Used by <see cref="WorldSerializer"/> for relationship fixup before adding.
|
||||
/// </summary>
|
||||
public Func<byte[], object> Deserialize { get; }
|
||||
|
||||
public ComponentDescriptor(
|
||||
string typeName,
|
||||
Type type,
|
||||
Func<object, byte[]> serialize,
|
||||
Action<World, Entity, byte[]> deserializeAndAdd)
|
||||
Action<World, Entity, byte[]> deserializeAndAdd,
|
||||
Func<byte[], object> deserialize)
|
||||
{
|
||||
TypeName = typeName;
|
||||
Type = type;
|
||||
Serialize = serialize;
|
||||
DeserializeAndAdd = deserializeAndAdd;
|
||||
Deserialize = deserialize;
|
||||
}
|
||||
}
|
||||
+99
-14
@@ -1,33 +1,41 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using MessagePack;
|
||||
|
||||
namespace OECS;
|
||||
|
||||
/// <summary>
|
||||
/// Registry of component types discovered at compile time by the
|
||||
/// OECS.SourceGen incremental generator. The consuming project's
|
||||
/// generated code populates this via a module initializer, so no
|
||||
/// manual registration is needed.
|
||||
/// Registry of component types used by <see cref="WorldSerializer"/>.
|
||||
/// Types are discovered via the OECS.SourceGen incremental generator
|
||||
/// at compile time, with a runtime fallback that scans loaded assemblies
|
||||
/// for <c>[MessagePackObject]</c> structs.
|
||||
/// </summary>
|
||||
public static class ComponentRegistry
|
||||
{
|
||||
private static ComponentDescriptor[]? _descriptors;
|
||||
private static Dictionary<string, ComponentDescriptor>? _byTypeName;
|
||||
private static bool _scanned;
|
||||
|
||||
/// <summary>
|
||||
/// All discovered component descriptors. Populated automatically
|
||||
/// by the source generator at module initialization.
|
||||
/// All discovered component descriptors.
|
||||
/// </summary>
|
||||
public static ComponentDescriptor[] Descriptors =>
|
||||
_descriptors ?? Array.Empty<ComponentDescriptor>();
|
||||
public static ComponentDescriptor[] Descriptors
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureScanned();
|
||||
return _descriptors ?? Array.Empty<ComponentDescriptor>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lookup by assembly-qualified type name. Populated automatically
|
||||
/// by the source generator at module initialization.
|
||||
/// Lookup by assembly-qualified type name.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, ComponentDescriptor> ByTypeName
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureScanned();
|
||||
if (_byTypeName == null)
|
||||
{
|
||||
var dict = new Dictionary<string, ComponentDescriptor>();
|
||||
@@ -41,11 +49,88 @@ public static class ComponentRegistry
|
||||
|
||||
/// <summary>
|
||||
/// Called by generated code to register discovered component types.
|
||||
/// Must be called before any serialization occurs.
|
||||
/// Multiple assemblies may call this. Descriptors are accumulated.
|
||||
/// </summary>
|
||||
public static void Initialize(ComponentDescriptor[] descriptors)
|
||||
public static void Register(ComponentDescriptor[] descriptors)
|
||||
{
|
||||
_descriptors = descriptors;
|
||||
_byTypeName = null; // Rebuild on next access.
|
||||
if (_descriptors == null)
|
||||
{
|
||||
_descriptors = descriptors;
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = new HashSet<string>(_descriptors.Select(d => d.TypeName));
|
||||
var merged = new List<ComponentDescriptor>(_descriptors);
|
||||
foreach (var d in descriptors)
|
||||
{
|
||||
if (!existing.Contains(d.TypeName))
|
||||
{
|
||||
existing.Add(d.TypeName);
|
||||
merged.Add(d);
|
||||
}
|
||||
}
|
||||
_descriptors = merged.ToArray();
|
||||
}
|
||||
_byTypeName = null;
|
||||
}
|
||||
|
||||
private static void EnsureScanned()
|
||||
{
|
||||
if (_scanned) return;
|
||||
_scanned = true;
|
||||
|
||||
// Runtime fallback: scan loaded assemblies for [MessagePackObject] structs.
|
||||
// This ensures serialization works even when the source generator
|
||||
// doesn't run (e.g., in test projects referencing game DLLs).
|
||||
var scanned = new List<ComponentDescriptor>(_descriptors ?? Array.Empty<ComponentDescriptor>());
|
||||
var seen = new HashSet<string>(scanned.Select(d => d.TypeName));
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var type in asm.GetTypes())
|
||||
{
|
||||
if (!type.IsValueType || type.IsAbstract || !type.IsPublic)
|
||||
continue;
|
||||
if (type.GetCustomAttribute<MessagePackObjectAttribute>() == null)
|
||||
continue;
|
||||
|
||||
var aqn = $"{type.FullName}, {type.Assembly.GetName().Name}";
|
||||
if (!seen.Add(aqn))
|
||||
continue;
|
||||
|
||||
var desc = CreateDescriptor(type, aqn);
|
||||
scanned.Add(desc);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Some assemblies may throw during reflection (e.g., mixed-mode).
|
||||
}
|
||||
}
|
||||
|
||||
_descriptors = scanned.ToArray();
|
||||
}
|
||||
|
||||
private static ComponentDescriptor CreateDescriptor(Type type, string aqn)
|
||||
{
|
||||
// Build serialize/deserialize delegates via reflection.
|
||||
var method = typeof(ComponentRegistry).GetMethod(
|
||||
nameof(CreateTypedDescriptor),
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
var generic = method.MakeGenericMethod(type);
|
||||
return (ComponentDescriptor)generic.Invoke(null, [aqn])!;
|
||||
}
|
||||
|
||||
private static ComponentDescriptor CreateTypedDescriptor<T>(string aqn) where T : struct
|
||||
{
|
||||
return new ComponentDescriptor(
|
||||
typeName: aqn,
|
||||
type: typeof(T),
|
||||
serialize: obj => MessagePackSerializer.Serialize((T)obj),
|
||||
deserializeAndAdd: (world, entity, data) =>
|
||||
world.AddComponent(entity, MessagePackSerializer.Deserialize<T>(data)),
|
||||
deserialize: data => MessagePackSerializer.Deserialize<T>(data));
|
||||
}
|
||||
}
|
||||
@@ -109,21 +109,4 @@ internal class ComponentStore
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of entities in the sparse set for type <typeparamref name="T"/>.
|
||||
/// Returns 0 if the set does not exist.
|
||||
/// </summary>
|
||||
public int Count<T>() where T : struct
|
||||
{
|
||||
return GetSetIfExists<T>()?.Count ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of entities in the sparse set for the given type.
|
||||
/// Returns 0 if the set does not exist.
|
||||
/// </summary>
|
||||
public int Count(Type componentType)
|
||||
{
|
||||
return GetSet(componentType)?.Count ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,24 +70,6 @@ internal class RelationshipIndex
|
||||
return sources;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all target entities that the given source entity points to
|
||||
/// via the specified relationship type. Returns an empty collection if none.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<Entity> GetTargets(Type relationshipType, Entity source)
|
||||
{
|
||||
if (!_index.TryGetValue(relationshipType, out var targetMap))
|
||||
return Array.Empty<Entity>();
|
||||
|
||||
var result = new List<Entity>();
|
||||
foreach (var (target, sources) in targetMap)
|
||||
{
|
||||
if (sources.Contains(source))
|
||||
result.Add(target);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all index entries where the given entity appears as a source.
|
||||
/// Called before the entity's components are removed during destruction.
|
||||
|
||||
@@ -132,29 +132,6 @@ internal class SparseSet<T> : ISparseSet where T : struct
|
||||
return ref _dense[index];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get the component value for the given entity.
|
||||
/// Returns true and copies the value to <paramref name="value"/> if present.
|
||||
/// </summary>
|
||||
public bool TryGet(Entity entity, out T value)
|
||||
{
|
||||
if (entity.Id >= (uint)_sparse.Length)
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
int index = _sparse[entity.Id];
|
||||
if (index == -1)
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = _dense[index];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ThrowEntityNotPresent(Entity entity)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
|
||||
@@ -181,6 +181,23 @@ public class World : IDisposable
|
||||
AddComponentImpl(entity, component);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a boxed component to the entity. Used by <see cref="WorldSerializer"/>
|
||||
/// during deserialization after relationship Source fixup.
|
||||
/// Calls <see cref="AddComponentImpl{T}"/> via reflection to avoid
|
||||
/// duplicating the relationship index logic.
|
||||
/// </summary>
|
||||
internal void AddComponentBoxed(Entity entity, object component, Type componentType)
|
||||
{
|
||||
var method = s_addComponentImpl.MakeGenericMethod(componentType);
|
||||
method.Invoke(this, [entity, component]);
|
||||
}
|
||||
|
||||
private static readonly System.Reflection.MethodInfo s_addComponentImpl =
|
||||
typeof(World).GetMethod(
|
||||
nameof(AddComponentImpl),
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
|
||||
private void AddComponentImpl<T>(Entity entity, T component) where T : struct
|
||||
{
|
||||
ThrowIfNotAlive(entity);
|
||||
|
||||
+26
-2
@@ -1,3 +1,4 @@
|
||||
using System.Reflection;
|
||||
using MessagePack;
|
||||
|
||||
namespace OECS;
|
||||
@@ -93,9 +94,32 @@ public static class WorldSerializer
|
||||
$"Ensure the component type is used with the World " +
|
||||
$"API so the source generator can discover it.");
|
||||
|
||||
// Deserialize and add in one typed operation — no reflection.
|
||||
desc.DeserializeAndAdd(world, entity, ce.Data);
|
||||
// Deserialize and fix up IRelationship.Source before adding.
|
||||
var component = desc.Deserialize(ce.Data);
|
||||
if (component is IRelationship rel)
|
||||
FixupRelationshipSource(rel, entity);
|
||||
|
||||
// Add via the typed internal method — no reflection for the add itself.
|
||||
world.AddComponentBoxed(entity, component, desc.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the Source field on a deserialized IRelationship to match
|
||||
/// the entity it's being restored to. Uses a small cache of PropertyInfo
|
||||
/// to avoid repeated reflection lookups.
|
||||
/// </summary>
|
||||
private static void FixupRelationshipSource(IRelationship rel, Entity entity)
|
||||
{
|
||||
var type = rel.GetType();
|
||||
if (!_sourcePropCache.TryGetValue(type, out var prop))
|
||||
{
|
||||
prop = type.GetProperty("Source");
|
||||
_sourcePropCache[type] = prop;
|
||||
}
|
||||
prop?.SetValue(rel, entity);
|
||||
}
|
||||
|
||||
private static readonly Dictionary<Type, PropertyInfo?> _sourcePropCache = new();
|
||||
}
|
||||
Reference in New Issue
Block a user