diff --git a/Game.Blackjack.Tests/GameFlowTests.cs b/Game.Blackjack.Tests/GameFlowTests.cs index 3218738..f004a29 100644 --- a/Game.Blackjack.Tests/GameFlowTests.cs +++ b/Game.Blackjack.Tests/GameFlowTests.cs @@ -172,8 +172,6 @@ public class GameFlowTests world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); group.RunLogical(); - var chipsBefore = world.ReadSingleton().Chips; - // Hit until bust or stand. while (world.ReadSingleton().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(); + state.Chips.Should().Be(90); + state.CurrentBet.Should().Be(10); + state.RoundNumber.Should().Be(1); + } + // ── Helpers ─────────────────────────────────────────────────────── private static (World, SystemGroup) SetupGame(uint seed = 42) diff --git a/Game.Blackjack.Tests/SnapshotTests.cs b/Game.Blackjack.Tests/SnapshotTests.cs new file mode 100644 index 0000000..39daf9c --- /dev/null +++ b/Game.Blackjack.Tests/SnapshotTests.cs @@ -0,0 +1,259 @@ +using FluentAssertions; +using Game.Blackjack; +using OECS; +using R3; +using Xunit; +using Xunit.Abstractions; + +namespace Game.Blackjack.Tests; + +/// +/// 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. +/// +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(); + + world.ObserveEntityChanges().Subscribe(change => + { + log.Add($"[entity] {change}"); + }); + + world.ObserveComponentChanges().Subscribe(change => + { + log.Add($"[card] {change}"); + }); + + world.ObserveComponentChanges().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(); + 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); + } + + /// + /// Produces a human-readable textual snapshot of the world state. + /// + private static string SnapshotWorld(World world) + { + var sb = new System.Text.StringBuilder(); + + // GameState singleton. + var state = world.ReadSingleton(); + 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(world); + if (deckEntity != Entity.Null) + { + var cardsInDeck = world.GetSources(deckEntity).ToList(); + sb.AppendLine($"Cards in deck: {cardsInDeck.Count}"); + } + + // Player hand. + var playerHand = FindEntity(world); + if (playerHand != Entity.Null) + { + var cards = world.GetSources(playerHand).ToList(); + sb.AppendLine($"Player hand: {cards.Count} cards"); + foreach (var cardEntity in cards) + { + var card = world.ReadComponent(cardEntity); + sb.AppendLine($" {card.Rank} of {card.Suit}"); + } + } + + // Dealer hand. + var dealerHand = FindEntity(world); + if (dealerHand != Entity.Null) + { + var cards = world.GetSources(dealerHand).ToList(); + sb.AppendLine($"Dealer hand: {cards.Count} cards"); + foreach (var cardEntity in cards) + { + var card = world.ReadComponent(cardEntity); + sb.AppendLine($" {card.Rank} of {card.Suit}"); + } + } + + // All entities. + int entityCount = 0; + using (var iter = world.Select()) + { + while (iter.MoveNext()) entityCount++; + } + sb.AppendLine($"Card entities: {entityCount}"); + + return sb.ToString().TrimEnd(); + } + + private static Entity FindEntity(World world) where T : struct + { + using var iter = world.Select(); + while (iter.MoveNext()) + { + if (iter.CurrentEntity != World.SingletonEntity) + return iter.CurrentEntity; + } + return Entity.Null; + } +} diff --git a/Game.Blackjack/Blackjack.csproj b/Game.Blackjack/Blackjack.csproj index c7392c2..85c71f6 100644 --- a/Game.Blackjack/Blackjack.csproj +++ b/Game.Blackjack/Blackjack.csproj @@ -9,9 +9,15 @@ + + + + + - + \ No newline at end of file diff --git a/Game.TicTacToe.Tests/SnapshotTests.cs b/Game.TicTacToe.Tests/SnapshotTests.cs new file mode 100644 index 0000000..f32c34c --- /dev/null +++ b/Game.TicTacToe.Tests/SnapshotTests.cs @@ -0,0 +1,244 @@ +using FluentAssertions; +using Game.TicTacToe; +using OECS; +using R3; +using Xunit; +using Xunit.Abstractions; + +namespace Game.TicTacToe.Tests; + +/// +/// 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. +/// +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(); + + // Subscribe to all entity-level changes. + world.ObserveEntityChanges().Subscribe(change => + { + log.Add($"[entity] {change}"); + }); + + // Subscribe to component-specific changes. + world.ObserveComponentChanges().Subscribe(change => + { + log.Add($"[mark] {change}"); + }); + + world.ObserveComponentChanges().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(); + } + } + + /// + /// Produces a human-readable textual snapshot of the world state. + /// + private static string SnapshotWorld(World world) + { + var sb = new System.Text.StringBuilder(); + + // Singleton: GameState. + var state = world.ReadSingleton(); + 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()) + { + 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(); + } +} diff --git a/OECS.SourceGen/ComponentDiscoveryGenerator.cs b/OECS.SourceGen/ComponentDiscoveryGenerator.cs index c0cf19b..795f828 100644 --- a/OECS.SourceGen/ComponentDiscoveryGenerator.cs +++ b/OECS.SourceGen/ComponentDiscoveryGenerator.cs @@ -38,6 +38,10 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator // Query building "With", "Without", + + // Iteration and relationships + "Select", + "GetSources", }; private static readonly HashSet _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(); + var typeMap = new Dictionary(); 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(" )"); } @@ -224,4 +238,4 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator context.AddSource("ComponentRegistry.g.cs", sb.ToString()); } -} \ No newline at end of file +} diff --git a/OECS/ComponentDescriptor.cs b/OECS/ComponentDescriptor.cs index c3f9252..6da6fd6 100644 --- a/OECS/ComponentDescriptor.cs +++ b/OECS/ComponentDescriptor.cs @@ -28,15 +28,23 @@ public sealed class ComponentDescriptor /// public Action DeserializeAndAdd { get; } + /// + /// Deserializes a MessagePack byte array to a boxed component object. + /// Used by for relationship fixup before adding. + /// + public Func Deserialize { get; } + public ComponentDescriptor( string typeName, Type type, Func serialize, - Action deserializeAndAdd) + Action deserializeAndAdd, + Func deserialize) { TypeName = typeName; Type = type; Serialize = serialize; DeserializeAndAdd = deserializeAndAdd; + Deserialize = deserialize; } } \ No newline at end of file diff --git a/OECS/ComponentRegistry.cs b/OECS/ComponentRegistry.cs index f17202d..ec6e6f4 100644 --- a/OECS/ComponentRegistry.cs +++ b/OECS/ComponentRegistry.cs @@ -1,33 +1,41 @@ +using System.Reflection; using System.Runtime.CompilerServices; +using MessagePack; namespace OECS; /// -/// 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 . +/// Types are discovered via the OECS.SourceGen incremental generator +/// at compile time, with a runtime fallback that scans loaded assemblies +/// for [MessagePackObject] structs. /// public static class ComponentRegistry { private static ComponentDescriptor[]? _descriptors; private static Dictionary? _byTypeName; + private static bool _scanned; /// - /// All discovered component descriptors. Populated automatically - /// by the source generator at module initialization. + /// All discovered component descriptors. /// - public static ComponentDescriptor[] Descriptors => - _descriptors ?? Array.Empty(); + public static ComponentDescriptor[] Descriptors + { + get + { + EnsureScanned(); + return _descriptors ?? Array.Empty(); + } + } /// - /// Lookup by assembly-qualified type name. Populated automatically - /// by the source generator at module initialization. + /// Lookup by assembly-qualified type name. /// public static IReadOnlyDictionary ByTypeName { get { + EnsureScanned(); if (_byTypeName == null) { var dict = new Dictionary(); @@ -41,11 +49,88 @@ public static class ComponentRegistry /// /// Called by generated code to register discovered component types. - /// Must be called before any serialization occurs. + /// Multiple assemblies may call this. Descriptors are accumulated. /// - 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(_descriptors.Select(d => d.TypeName)); + var merged = new List(_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(_descriptors ?? Array.Empty()); + var seen = new HashSet(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() == 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(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(data)), + deserialize: data => MessagePackSerializer.Deserialize(data)); } } \ No newline at end of file diff --git a/OECS/ComponentStore.cs b/OECS/ComponentStore.cs index 93726a7..5bfea0c 100644 --- a/OECS/ComponentStore.cs +++ b/OECS/ComponentStore.cs @@ -109,21 +109,4 @@ internal class ComponentStore return set; } - /// - /// Returns the count of entities in the sparse set for type . - /// Returns 0 if the set does not exist. - /// - public int Count() where T : struct - { - return GetSetIfExists()?.Count ?? 0; - } - - /// - /// Returns the count of entities in the sparse set for the given type. - /// Returns 0 if the set does not exist. - /// - public int Count(Type componentType) - { - return GetSet(componentType)?.Count ?? 0; - } } diff --git a/OECS/RelationshipIndex.cs b/OECS/RelationshipIndex.cs index d5f7684..1436da4 100644 --- a/OECS/RelationshipIndex.cs +++ b/OECS/RelationshipIndex.cs @@ -70,24 +70,6 @@ internal class RelationshipIndex return sources; } - /// - /// Returns all target entities that the given source entity points to - /// via the specified relationship type. Returns an empty collection if none. - /// - public IReadOnlyCollection GetTargets(Type relationshipType, Entity source) - { - if (!_index.TryGetValue(relationshipType, out var targetMap)) - return Array.Empty(); - - var result = new List(); - foreach (var (target, sources) in targetMap) - { - if (sources.Contains(source)) - result.Add(target); - } - return result; - } - /// /// Removes all index entries where the given entity appears as a source. /// Called before the entity's components are removed during destruction. diff --git a/OECS/SparseSet.cs b/OECS/SparseSet.cs index e6842da..322e7fc 100644 --- a/OECS/SparseSet.cs +++ b/OECS/SparseSet.cs @@ -132,29 +132,6 @@ internal class SparseSet : ISparseSet where T : struct return ref _dense[index]; } - /// - /// Tries to get the component value for the given entity. - /// Returns true and copies the value to if present. - /// - 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( diff --git a/OECS/World.cs b/OECS/World.cs index cadf8bb..a1cc480 100644 --- a/OECS/World.cs +++ b/OECS/World.cs @@ -181,6 +181,23 @@ public class World : IDisposable AddComponentImpl(entity, component); } + /// + /// Adds a boxed component to the entity. Used by + /// during deserialization after relationship Source fixup. + /// Calls via reflection to avoid + /// duplicating the relationship index logic. + /// + 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(Entity entity, T component) where T : struct { ThrowIfNotAlive(entity); diff --git a/OECS/WorldSerializer.cs b/OECS/WorldSerializer.cs index 4877ebf..173545c 100644 --- a/OECS/WorldSerializer.cs +++ b/OECS/WorldSerializer.cs @@ -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); } } } + + /// + /// 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. + /// + 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 _sourcePropCache = new(); } \ No newline at end of file diff --git a/docs/testing-games.md b/docs/testing-games.md index 8272555..304cda9 100644 --- a/docs/testing-games.md +++ b/docs/testing-games.md @@ -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.