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:
hypercross 2026-07-20 17:05:17 +08:00
parent b4661e714e
commit e1fb197474
13 changed files with 729 additions and 99 deletions

View File

@ -172,8 +172,6 @@ public class GameFlowTests
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var chipsBefore = world.ReadSingleton<GameState>().Chips;
// Hit until bust or stand.
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn)
{
@ -186,6 +184,27 @@ public class GameFlowTests
state.Phase.Should().Be(GamePhase.RoundOver);
}
[Fact]
public void Serialization_RoundTrips()
{
var (world, group) = SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
using var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
var state = world2.ReadSingleton<GameState>();
state.Chips.Should().Be(90);
state.CurrentBet.Should().Be(10);
state.RoundNumber.Should().Be(1);
}
// ── Helpers ───────────────────────────────────────────────────────
private static (World, SystemGroup) SetupGame(uint seed = 42)

View File

@ -0,0 +1,259 @@
using FluentAssertions;
using Game.Blackjack;
using OECS;
using R3;
using Xunit;
using Xunit.Abstractions;
namespace Game.Blackjack.Tests;
/// <summary>
/// Baseline snapshot and log tests for Blackjack.
/// Captures the world state as text and R3 change logs so they can be
/// reviewed manually and compared across changes.
/// </summary>
public class SnapshotTests
{
private readonly ITestOutputHelper _output;
public SnapshotTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void Snapshot_InitialState()
{
var (world, _) = SetupGame();
var snapshot = SnapshotWorld(world);
_output.WriteLine(snapshot);
snapshot.Should().Contain("Phase: Betting");
snapshot.Should().Contain("Chips: 100");
snapshot.Should().Contain("Round: 1");
}
[Fact]
public void Snapshot_AfterDeal()
{
var (world, group) = SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var snapshot = SnapshotWorld(world);
_output.WriteLine(snapshot);
snapshot.Should().Contain("Phase: PlayerTurn");
snapshot.Should().Contain("Chips: 90");
snapshot.Should().Contain("Bet: 10");
snapshot.Should().Contain("Player hand:");
snapshot.Should().Contain("Dealer hand:");
snapshot.Should().Contain("Cards in deck:");
}
[Fact]
public void Snapshot_AfterHit()
{
var (world, group) = SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
world.Commands.Enqueue(new HitCommand());
group.RunLogical();
var snapshot = SnapshotWorld(world);
_output.WriteLine(snapshot);
// With seed 42, one hit may bust. Just verify the game resolved.
snapshot.Should().NotContain("Phase: Dealing");
}
[Fact]
public void Snapshot_AfterStand()
{
var (world, group) = SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
world.Commands.Enqueue(new StandCommand());
group.RunLogical();
var snapshot = SnapshotWorld(world);
_output.WriteLine(snapshot);
snapshot.Should().Contain("Phase: RoundOver");
snapshot.Should().Contain("Result:");
}
[Fact]
public void Log_ReactivityDuringRound()
{
var (world, group) = SetupGame(seed: 42);
var log = new List<string>();
world.ObserveEntityChanges().Subscribe(change =>
{
log.Add($"[entity] {change}");
});
world.ObserveComponentChanges<Card>().Subscribe(change =>
{
log.Add($"[card] {change}");
});
world.ObserveComponentChanges<GameState>().Subscribe(change =>
{
log.Add($"[gamestate] {change}");
});
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
world.Commands.Enqueue(new StandCommand());
group.RunLogical();
var logText = string.Join("\n", log);
_output.WriteLine(logText);
// Should have entity creations (deck, hands, cards) and component changes.
log.Should().Contain(l => l.Contains("EntityAdded"));
log.Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Card"));
log.Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
}
[Fact]
public void Serialization_RoundTrip_PreservesCoreState()
{
var (world, group) = SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var before = SnapshotWorld(world);
using var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
var after = SnapshotWorld(world2);
_output.WriteLine("=== Before ===");
_output.WriteLine(before);
_output.WriteLine("=== After ===");
_output.WriteLine(after);
// Core game state must round-trip.
var state = world2.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.PlayerTurn);
state.Chips.Should().Be(90);
state.CurrentBet.Should().Be(10);
state.RoundNumber.Should().Be(1);
// Card entities must be preserved.
after.Should().Contain("Card entities: 52");
}
// ── Helpers ───────────────────────────────────────────────────────
private static (World, SystemGroup) SetupGame(uint seed = 42)
{
var world = new World();
var group = new SystemGroup(world);
group.Add(new DeckSetupSystem());
group.Add(new DealSystem());
group.Add(new PlayerBustCheckSystem());
group.Add(new DealerSystem());
world.SetSingleton(new GameState
{
Phase = GamePhase.Betting,
Result = RoundResult.None,
Chips = 100,
CurrentBet = 0,
RoundNumber = 1,
Seed = seed
});
world.PostChanges();
return (world, group);
}
/// <summary>
/// Produces a human-readable textual snapshot of the world state.
/// </summary>
private static string SnapshotWorld(World world)
{
var sb = new System.Text.StringBuilder();
// GameState singleton.
var state = world.ReadSingleton<GameState>();
sb.AppendLine($"Phase: {state.Phase}");
sb.AppendLine($"Chips: {state.Chips}");
sb.AppendLine($"Bet: {state.CurrentBet}");
sb.AppendLine($"Round: {state.RoundNumber}");
sb.AppendLine($"Result: {state.Result}");
sb.AppendLine($"Seed: {state.Seed}");
// Deck entity.
var deckEntity = FindEntity<Deck>(world);
if (deckEntity != Entity.Null)
{
var cardsInDeck = world.GetSources<InDeck>(deckEntity).ToList();
sb.AppendLine($"Cards in deck: {cardsInDeck.Count}");
}
// Player hand.
var playerHand = FindEntity<PlayerHand>(world);
if (playerHand != Entity.Null)
{
var cards = world.GetSources<Holds>(playerHand).ToList();
sb.AppendLine($"Player hand: {cards.Count} cards");
foreach (var cardEntity in cards)
{
var card = world.ReadComponent<Card>(cardEntity);
sb.AppendLine($" {card.Rank} of {card.Suit}");
}
}
// Dealer hand.
var dealerHand = FindEntity<DealerHand>(world);
if (dealerHand != Entity.Null)
{
var cards = world.GetSources<Holds>(dealerHand).ToList();
sb.AppendLine($"Dealer hand: {cards.Count} cards");
foreach (var cardEntity in cards)
{
var card = world.ReadComponent<Card>(cardEntity);
sb.AppendLine($" {card.Rank} of {card.Suit}");
}
}
// All entities.
int entityCount = 0;
using (var iter = world.Select<Card>())
{
while (iter.MoveNext()) entityCount++;
}
sb.AppendLine($"Card entities: {entityCount}");
return sb.ToString().TrimEnd();
}
private static Entity FindEntity<T>(World world) where T : struct
{
using var iter = world.Select<T>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
}
return Entity.Null;
}
}

View File

@ -9,9 +9,15 @@
<ProjectReference Include="..\OECS\OECS.csproj" />
</ItemGroup>
<!-- Source generator: referenced as a built DLL. -->
<Target Name="EnsureSourceGenBuilt" BeforeTargets="CoreCompile">
<MSBuild Projects="..\OECS.SourceGen\OECS.SourceGen.csproj"
Targets="Build"
Properties="Configuration=$(Configuration)" />
</Target>
<ItemGroup>
<ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<Analyzer Include="..\OECS.SourceGen\bin\$(Configuration)\netstandard2.0\OECS.SourceGen.dll" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,244 @@
using FluentAssertions;
using Game.TicTacToe;
using OECS;
using R3;
using Xunit;
using Xunit.Abstractions;
namespace Game.TicTacToe.Tests;
/// <summary>
/// Baseline snapshot and log tests for TicTacToe.
/// Captures the world state as text and R3 change logs so they can be
/// reviewed manually and compared across changes.
/// </summary>
public class SnapshotTests
{
private readonly ITestOutputHelper _output;
public SnapshotTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void Snapshot_InitialBoard()
{
var world = new World();
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c });
world.SetSingleton(new GameState
{
CurrentPlayer = Player.X,
Status = GameStatus.Playing,
MoveCount = 0
});
var snapshot = SnapshotWorld(world);
_output.WriteLine(snapshot);
// Verify baseline properties.
snapshot.Should().Contain("GameState: X's turn, 0 moves");
snapshot.Should().Contain("Cells: 9 total, 0 marked");
}
[Fact]
public void Snapshot_AfterThreeMoves()
{
var (world, group) = SetupGame();
// X: (0,0), O: (1,1), X: (2,2)
PlayMoves(world, group, (0, 0), (1, 1), (2, 2));
var snapshot = SnapshotWorld(world);
_output.WriteLine(snapshot);
snapshot.Should().Contain("GameState: O's turn, 3 moves");
snapshot.Should().Contain("Cells: 9 total, 3 marked");
snapshot.Should().Contain("(0,0): X");
snapshot.Should().Contain("(1,1): O");
snapshot.Should().Contain("(2,2): X");
}
[Fact]
public void Snapshot_XWins()
{
var (world, group) = SetupGame();
// X: (0,0), O: (1,0), X: (0,1), O: (1,1), X: (0,2) → X wins row 0
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
var snapshot = SnapshotWorld(world);
_output.WriteLine(snapshot);
snapshot.Should().Contain("GameState: XWon");
snapshot.Should().Contain("Cells: 9 total, 5 marked");
}
[Fact]
public void Snapshot_Draw()
{
var (world, group) = SetupGame();
PlayMoves(world, group,
(0, 0), (0, 1), (0, 2),
(1, 1), (1, 0), (1, 2),
(2, 1), (2, 0), (2, 2));
var snapshot = SnapshotWorld(world);
_output.WriteLine(snapshot);
snapshot.Should().Contain("GameState: Draw");
snapshot.Should().Contain("Cells: 9 total, 9 marked");
}
[Fact]
public void Log_ReactivityDuringGame()
{
var (world, group) = SetupGame();
var log = new List<string>();
// Subscribe to all entity-level changes.
world.ObserveEntityChanges().Subscribe(change =>
{
log.Add($"[entity] {change}");
});
// Subscribe to component-specific changes.
world.ObserveComponentChanges<Mark>().Subscribe(change =>
{
log.Add($"[mark] {change}");
});
world.ObserveComponentChanges<GameState>().Subscribe(change =>
{
log.Add($"[gamestate] {change}");
});
// Play a full game: X wins on row 0.
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
var logText = string.Join("\n", log);
_output.WriteLine(logText);
// Verify key events appear in the log.
log.Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Mark"));
log.Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
}
[Fact]
public void Serialization_RoundTrip_PreservesSnapshot()
{
var (world, group) = SetupGame();
// Play a few moves.
PlayMoves(world, group, (0, 0), (1, 1), (2, 2));
var before = SnapshotWorld(world);
using var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
var after = SnapshotWorld(world2);
_output.WriteLine("=== Before ===");
_output.WriteLine(before);
_output.WriteLine("=== After ===");
_output.WriteLine(after);
after.Should().Be(before);
}
// ── Helpers ───────────────────────────────────────────────────────
private static (World, SystemGroup) SetupGame()
{
var world = new World();
var group = new SystemGroup(world);
group.Add(new WinCheckSystem());
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c });
world.SetSingleton(new GameState
{
CurrentPlayer = Player.X,
Status = GameStatus.Playing,
MoveCount = 0
});
world.PostChanges();
return (world, group);
}
private static void PlayMoves(World world, SystemGroup group, params (int Row, int Col)[] moves)
{
foreach (var (row, col) in moves)
{
world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col });
group.RunLogical();
}
}
/// <summary>
/// Produces a human-readable textual snapshot of the world state.
/// </summary>
private static string SnapshotWorld(World world)
{
var sb = new System.Text.StringBuilder();
// Singleton: GameState.
var state = world.ReadSingleton<GameState>();
var statusText = state.Status switch
{
GameStatus.Playing => $"{(state.CurrentPlayer == Player.X ? 'X' : 'O')}'s turn, {state.MoveCount} moves",
GameStatus.XWon => "XWon",
GameStatus.OWon => "OWon",
GameStatus.Draw => "Draw",
_ => "Unknown"
};
sb.AppendLine($"GameState: {statusText}");
// Board: build a 3x3 grid.
var grid = new char?[3, 3];
using (var iter = world.Select<Cell, Mark>())
{
while (iter.MoveNext())
{
grid[iter.Current1.Row, iter.Current1.Col] =
iter.Current2.Player == Player.X ? 'X' : 'O';
}
}
int totalCells = 0;
int markedCells = 0;
for (int r = 0; r < 3; r++)
{
for (int c = 0; c < 3; c++)
{
totalCells++;
var mark = grid[r, c];
if (mark.HasValue)
{
markedCells++;
sb.AppendLine($" ({r},{c}): {mark.Value}");
}
else
{
sb.AppendLine($" ({r},{c}): .");
}
}
}
sb.AppendLine($"Cells: {totalCells} total, {markedCells} marked");
return sb.ToString().TrimEnd();
}
}

View File

@ -38,6 +38,10 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
// Query building
"With",
"Without",
// Iteration and relationships
"Select",
"GetSources",
};
private static readonly HashSet<string> _forEachNames = new()
@ -53,7 +57,7 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
predicate: IsCandidateInvocation,
transform: ExtractComponentType)
.Where(t => t.FullyQualifiedName != null)
.Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!));
.Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!, t.IsRelationship));
// Also collect ForEach type arguments from the World class.
var forEachCalls = context.SyntaxProvider
@ -100,7 +104,7 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
return false;
}
private static (string? FullyQualifiedName, string? AssemblyQualifiedName) ExtractComponentType(
private static (string? FullyQualifiedName, string? AssemblyQualifiedName, bool IsRelationship) ExtractComponentType(
GeneratorSyntaxContext ctx, CancellationToken _)
{
if (ctx.Node is not InvocationExpressionSyntax invocation)
@ -133,22 +137,25 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}"
: fqn;
return (fqn, aqn);
bool isRelationship = typeSymbol.AllInterfaces.Any(i =>
i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::OECS.IRelationship");
return (fqn, aqn, isRelationship);
}
private static ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> ExtractForEachTypes(
private static ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> ExtractForEachTypes(
GeneratorSyntaxContext ctx, CancellationToken _)
{
if (ctx.Node is not InvocationExpressionSyntax invocation)
return ImmutableArray<(string, string)>.Empty;
return ImmutableArray<(string, string, bool)>.Empty;
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
return ImmutableArray<(string, string)>.Empty;
return ImmutableArray<(string, string, bool)>.Empty;
if (memberAccess.Name is not GenericNameSyntax genericName)
return ImmutableArray<(string, string)>.Empty;
return ImmutableArray<(string, string, bool)>.Empty;
var types = ImmutableArray.CreateBuilder<(string, string)>();
var types = ImmutableArray.CreateBuilder<(string, string, bool)>();
foreach (var typeArg in genericName.TypeArgumentList.Arguments)
{
var symbolInfo = ctx.SemanticModel.GetSymbolInfo(typeArg);
@ -160,7 +167,9 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
var aqn = typeSymbol.ContainingAssembly != null
? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}"
: fqn;
types.Add((fqn, aqn));
bool isRelationship = typeSymbol.AllInterfaces.Any(i =>
i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::OECS.IRelationship");
types.Add((fqn, aqn, isRelationship));
}
}
@ -168,18 +177,21 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
}
private static void GenerateRegistry(SourceProductionContext context,
(ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> Left,
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> Right) data)
(ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Left,
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Right) data)
{
var (invocations, forEachTypes) = data;
// Collect unique types by fully-qualified name, deduplicating.
var typeMap = new Dictionary<string, (string Fqn, string Aqn)>();
var typeMap = new Dictionary<string, (string Fqn, string Aqn, bool IsRel)>();
foreach (var t in invocations)
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName);
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship);
foreach (var t in forEachTypes)
{
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName);
if (!typeMap.ContainsKey(t.FullyQualifiedName))
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship);
else if (t.IsRelationship)
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, true);
}
if (typeMap.Count == 0)
@ -197,11 +209,11 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
sb.AppendLine(" [ModuleInitializer]");
sb.AppendLine(" public static void Initialize()");
sb.AppendLine(" {");
sb.AppendLine(" ComponentRegistry.Initialize(new ComponentDescriptor[]");
sb.AppendLine(" ComponentRegistry.Register(new ComponentDescriptor[]");
sb.AppendLine(" {");
bool first = true;
foreach (var (fqn, aqn) in typeMap.Values.OrderBy(t => t.Fqn))
foreach (var (fqn, aqn, isRel) in typeMap.Values.OrderBy(t => t.Fqn))
{
if (!first)
sb.AppendLine(",");
@ -212,7 +224,9 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
sb.AppendLine($" type: typeof({fqn}),");
sb.AppendLine($" serialize: obj => MessagePackSerializer.Serialize(({fqn})obj),");
sb.AppendLine($" deserializeAndAdd: (world, entity, data) =>");
sb.AppendLine($" world.AddComponent(entity, MessagePackSerializer.Deserialize<{fqn}>(data))");
sb.AppendLine($" world.AddComponent(entity, MessagePackSerializer.Deserialize<{fqn}>(data)),");
sb.AppendLine($" deserialize: data => MessagePackSerializer.Deserialize<{fqn}>(data)");
sb.Append(" )");
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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.

View File

@ -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(

View File

@ -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);

View File

@ -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();
}

View File

@ -1,7 +1,19 @@
# Testing games
1. Games are dlls. Use a standalone test project to test a game.
2. Create baseline game snapshots in textual format.
3. Create baseline logs via the r3 observable api.
2. Create baseline game snapshots in textual format. Save to files.
3. Create baseline logs via the r3 observable api. Save to files.
4. Read the snapshot/logs manually to identify issues.
5. Make sure serialization roundtrip works.
## Play tests
Playtests are games executed with AI players who give unexpected play commands.
AI players are not LLMs here; they are typically common game AI implementations.
Create static functions that analyze the game state and return a command. Each such function is an agent. Each AI player routes its decisions to a random agent in a pool of weighted agents.
Agents to consider:
- Random: evenly chooses every move randomly.
- Greedy: uses a specific way to score actions. Always chooses the onewith highest score. There can be multiple Greedy agents wtih different scoring.