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 }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical(); group.RunLogical();
var chipsBefore = world.ReadSingleton<GameState>().Chips;
// Hit until bust or stand. // Hit until bust or stand.
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn) while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn)
{ {
@ -186,6 +184,27 @@ public class GameFlowTests
state.Phase.Should().Be(GamePhase.RoundOver); 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 ─────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────
private static (World, SystemGroup) SetupGame(uint seed = 42) 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" /> <ProjectReference Include="..\OECS\OECS.csproj" />
</ItemGroup> </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> <ItemGroup>
<ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj" <Analyzer Include="..\OECS.SourceGen\bin\$(Configuration)\netstandard2.0\OECS.SourceGen.dll" />
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup> </ItemGroup>
</Project> </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 // Query building
"With", "With",
"Without", "Without",
// Iteration and relationships
"Select",
"GetSources",
}; };
private static readonly HashSet<string> _forEachNames = new() private static readonly HashSet<string> _forEachNames = new()
@ -53,7 +57,7 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
predicate: IsCandidateInvocation, predicate: IsCandidateInvocation,
transform: ExtractComponentType) transform: ExtractComponentType)
.Where(t => t.FullyQualifiedName != null) .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. // Also collect ForEach type arguments from the World class.
var forEachCalls = context.SyntaxProvider var forEachCalls = context.SyntaxProvider
@ -100,7 +104,7 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
return false; return false;
} }
private static (string? FullyQualifiedName, string? AssemblyQualifiedName) ExtractComponentType( private static (string? FullyQualifiedName, string? AssemblyQualifiedName, bool IsRelationship) ExtractComponentType(
GeneratorSyntaxContext ctx, CancellationToken _) GeneratorSyntaxContext ctx, CancellationToken _)
{ {
if (ctx.Node is not InvocationExpressionSyntax invocation) 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}" ? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}"
: fqn; : 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 _) GeneratorSyntaxContext ctx, CancellationToken _)
{ {
if (ctx.Node is not InvocationExpressionSyntax invocation) 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) 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) 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) foreach (var typeArg in genericName.TypeArgumentList.Arguments)
{ {
var symbolInfo = ctx.SemanticModel.GetSymbolInfo(typeArg); var symbolInfo = ctx.SemanticModel.GetSymbolInfo(typeArg);
@ -160,7 +167,9 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
var aqn = typeSymbol.ContainingAssembly != null var aqn = typeSymbol.ContainingAssembly != null
? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}" ? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}"
: fqn; : 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, private static void GenerateRegistry(SourceProductionContext context,
(ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> Left, (ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Left,
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> Right) data) ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Right) data)
{ {
var (invocations, forEachTypes) = data; var (invocations, forEachTypes) = data;
// Collect unique types by fully-qualified name, deduplicating. // 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) 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) 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) if (typeMap.Count == 0)
@ -197,11 +209,11 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
sb.AppendLine(" [ModuleInitializer]"); sb.AppendLine(" [ModuleInitializer]");
sb.AppendLine(" public static void Initialize()"); sb.AppendLine(" public static void Initialize()");
sb.AppendLine(" {"); sb.AppendLine(" {");
sb.AppendLine(" ComponentRegistry.Initialize(new ComponentDescriptor[]"); sb.AppendLine(" ComponentRegistry.Register(new ComponentDescriptor[]");
sb.AppendLine(" {"); sb.AppendLine(" {");
bool first = true; 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) if (!first)
sb.AppendLine(","); sb.AppendLine(",");
@ -212,7 +224,9 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
sb.AppendLine($" type: typeof({fqn}),"); sb.AppendLine($" type: typeof({fqn}),");
sb.AppendLine($" serialize: obj => MessagePackSerializer.Serialize(({fqn})obj),"); sb.AppendLine($" serialize: obj => MessagePackSerializer.Serialize(({fqn})obj),");
sb.AppendLine($" deserializeAndAdd: (world, entity, data) =>"); 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(" )"); sb.Append(" )");
} }
@ -224,4 +238,4 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
context.AddSource("ComponentRegistry.g.cs", sb.ToString()); context.AddSource("ComponentRegistry.g.cs", sb.ToString());
} }
} }

View File

@ -28,15 +28,23 @@ public sealed class ComponentDescriptor
/// </summary> /// </summary>
public Action<World, Entity, byte[]> DeserializeAndAdd { get; } 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( public ComponentDescriptor(
string typeName, string typeName,
Type type, Type type,
Func<object, byte[]> serialize, Func<object, byte[]> serialize,
Action<World, Entity, byte[]> deserializeAndAdd) Action<World, Entity, byte[]> deserializeAndAdd,
Func<byte[], object> deserialize)
{ {
TypeName = typeName; TypeName = typeName;
Type = type; Type = type;
Serialize = serialize; Serialize = serialize;
DeserializeAndAdd = deserializeAndAdd; DeserializeAndAdd = deserializeAndAdd;
Deserialize = deserialize;
} }
} }

View File

@ -1,33 +1,41 @@
using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using MessagePack;
namespace OECS; namespace OECS;
/// <summary> /// <summary>
/// Registry of component types discovered at compile time by the /// Registry of component types used by <see cref="WorldSerializer"/>.
/// OECS.SourceGen incremental generator. The consuming project's /// Types are discovered via the OECS.SourceGen incremental generator
/// generated code populates this via a module initializer, so no /// at compile time, with a runtime fallback that scans loaded assemblies
/// manual registration is needed. /// for <c>[MessagePackObject]</c> structs.
/// </summary> /// </summary>
public static class ComponentRegistry public static class ComponentRegistry
{ {
private static ComponentDescriptor[]? _descriptors; private static ComponentDescriptor[]? _descriptors;
private static Dictionary<string, ComponentDescriptor>? _byTypeName; private static Dictionary<string, ComponentDescriptor>? _byTypeName;
private static bool _scanned;
/// <summary> /// <summary>
/// All discovered component descriptors. Populated automatically /// All discovered component descriptors.
/// by the source generator at module initialization.
/// </summary> /// </summary>
public static ComponentDescriptor[] Descriptors => public static ComponentDescriptor[] Descriptors
_descriptors ?? Array.Empty<ComponentDescriptor>(); {
get
{
EnsureScanned();
return _descriptors ?? Array.Empty<ComponentDescriptor>();
}
}
/// <summary> /// <summary>
/// Lookup by assembly-qualified type name. Populated automatically /// Lookup by assembly-qualified type name.
/// by the source generator at module initialization.
/// </summary> /// </summary>
public static IReadOnlyDictionary<string, ComponentDescriptor> ByTypeName public static IReadOnlyDictionary<string, ComponentDescriptor> ByTypeName
{ {
get get
{ {
EnsureScanned();
if (_byTypeName == null) if (_byTypeName == null)
{ {
var dict = new Dictionary<string, ComponentDescriptor>(); var dict = new Dictionary<string, ComponentDescriptor>();
@ -41,11 +49,88 @@ public static class ComponentRegistry
/// <summary> /// <summary>
/// Called by generated code to register discovered component types. /// 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> /// </summary>
public static void Initialize(ComponentDescriptor[] descriptors) public static void Register(ComponentDescriptor[] descriptors)
{ {
_descriptors = descriptors; if (_descriptors == null)
_byTypeName = null; // Rebuild on next access. {
_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; 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; 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> /// <summary>
/// Removes all index entries where the given entity appears as a source. /// Removes all index entries where the given entity appears as a source.
/// Called before the entity's components are removed during destruction. /// 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]; 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) private static void ThrowEntityNotPresent(Entity entity)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(

View File

@ -181,6 +181,23 @@ public class World : IDisposable
AddComponentImpl(entity, component); 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 private void AddComponentImpl<T>(Entity entity, T component) where T : struct
{ {
ThrowIfNotAlive(entity); ThrowIfNotAlive(entity);

View File

@ -1,3 +1,4 @@
using System.Reflection;
using MessagePack; using MessagePack;
namespace OECS; namespace OECS;
@ -93,9 +94,32 @@ public static class WorldSerializer
$"Ensure the component type is used with the World " + $"Ensure the component type is used with the World " +
$"API so the source generator can discover it."); $"API so the source generator can discover it.");
// Deserialize and add in one typed operation — no reflection. // Deserialize and fix up IRelationship.Source before adding.
desc.DeserializeAndAdd(world, entity, ce.Data); 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 # Testing games
1. Games are dlls. Use a standalone test project to test a game. 1. Games are dlls. Use a standalone test project to test a game.
2. Create baseline game snapshots in textual format. 2. Create baseline game snapshots in textual format. Save to files.
3. Create baseline logs via the r3 observable api. 3. Create baseline logs via the r3 observable api. Save to files.
4. Read the snapshot/logs manually to identify issues. 4. Read the snapshot/logs manually to identify issues.
5. Make sure serialization roundtrip works. 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.