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:
parent
b4661e714e
commit
e1fb197474
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(" )");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
if (_descriptors == null)
|
||||
{
|
||||
_descriptors = descriptors;
|
||||
_byTypeName = null; // Rebuild on next access.
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = new HashSet<string>(_descriptors.Select(d => d.TypeName));
|
||||
var merged = new List<ComponentDescriptor>(_descriptors);
|
||||
foreach (var d in descriptors)
|
||||
{
|
||||
if (!existing.Contains(d.TypeName))
|
||||
{
|
||||
existing.Add(d.TypeName);
|
||||
merged.Add(d);
|
||||
}
|
||||
}
|
||||
_descriptors = merged.ToArray();
|
||||
}
|
||||
_byTypeName = null;
|
||||
}
|
||||
|
||||
private static void EnsureScanned()
|
||||
{
|
||||
if (_scanned) return;
|
||||
_scanned = true;
|
||||
|
||||
// Runtime fallback: scan loaded assemblies for [MessagePackObject] structs.
|
||||
// This ensures serialization works even when the source generator
|
||||
// doesn't run (e.g., in test projects referencing game DLLs).
|
||||
var scanned = new List<ComponentDescriptor>(_descriptors ?? Array.Empty<ComponentDescriptor>());
|
||||
var seen = new HashSet<string>(scanned.Select(d => d.TypeName));
|
||||
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var type in asm.GetTypes())
|
||||
{
|
||||
if (!type.IsValueType || type.IsAbstract || !type.IsPublic)
|
||||
continue;
|
||||
if (type.GetCustomAttribute<MessagePackObjectAttribute>() == null)
|
||||
continue;
|
||||
|
||||
var aqn = $"{type.FullName}, {type.Assembly.GetName().Name}";
|
||||
if (!seen.Add(aqn))
|
||||
continue;
|
||||
|
||||
var desc = CreateDescriptor(type, aqn);
|
||||
scanned.Add(desc);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Some assemblies may throw during reflection (e.g., mixed-mode).
|
||||
}
|
||||
}
|
||||
|
||||
_descriptors = scanned.ToArray();
|
||||
}
|
||||
|
||||
private static ComponentDescriptor CreateDescriptor(Type type, string aqn)
|
||||
{
|
||||
// Build serialize/deserialize delegates via reflection.
|
||||
var method = typeof(ComponentRegistry).GetMethod(
|
||||
nameof(CreateTypedDescriptor),
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
var generic = method.MakeGenericMethod(type);
|
||||
return (ComponentDescriptor)generic.Invoke(null, [aqn])!;
|
||||
}
|
||||
|
||||
private static ComponentDescriptor CreateTypedDescriptor<T>(string aqn) where T : struct
|
||||
{
|
||||
return new ComponentDescriptor(
|
||||
typeName: aqn,
|
||||
type: typeof(T),
|
||||
serialize: obj => MessagePackSerializer.Serialize((T)obj),
|
||||
deserializeAndAdd: (world, entity, data) =>
|
||||
world.AddComponent(entity, MessagePackSerializer.Deserialize<T>(data)),
|
||||
deserialize: data => MessagePackSerializer.Deserialize<T>(data));
|
||||
}
|
||||
}
|
||||
|
|
@ -109,21 +109,4 @@ internal class ComponentStore
|
|||
return set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of entities in the sparse set for type <typeparamref name="T"/>.
|
||||
/// Returns 0 if the set does not exist.
|
||||
/// </summary>
|
||||
public int Count<T>() where T : struct
|
||||
{
|
||||
return GetSetIfExists<T>()?.Count ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of entities in the sparse set for the given type.
|
||||
/// Returns 0 if the set does not exist.
|
||||
/// </summary>
|
||||
public int Count(Type componentType)
|
||||
{
|
||||
return GetSet(componentType)?.Count ?? 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,24 +70,6 @@ internal class RelationshipIndex
|
|||
return sources;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all target entities that the given source entity points to
|
||||
/// via the specified relationship type. Returns an empty collection if none.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<Entity> GetTargets(Type relationshipType, Entity source)
|
||||
{
|
||||
if (!_index.TryGetValue(relationshipType, out var targetMap))
|
||||
return Array.Empty<Entity>();
|
||||
|
||||
var result = new List<Entity>();
|
||||
foreach (var (target, sources) in targetMap)
|
||||
{
|
||||
if (sources.Contains(source))
|
||||
result.Add(target);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all index entries where the given entity appears as a source.
|
||||
/// Called before the entity's components are removed during destruction.
|
||||
|
|
|
|||
|
|
@ -132,29 +132,6 @@ internal class SparseSet<T> : ISparseSet where T : struct
|
|||
return ref _dense[index];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get the component value for the given entity.
|
||||
/// Returns true and copies the value to <paramref name="value"/> if present.
|
||||
/// </summary>
|
||||
public bool TryGet(Entity entity, out T value)
|
||||
{
|
||||
if (entity.Id >= (uint)_sparse.Length)
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
int index = _sparse[entity.Id];
|
||||
if (index == -1)
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = _dense[index];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ThrowEntityNotPresent(Entity entity)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
|
|
|
|||
|
|
@ -181,6 +181,23 @@ public class World : IDisposable
|
|||
AddComponentImpl(entity, component);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a boxed component to the entity. Used by <see cref="WorldSerializer"/>
|
||||
/// during deserialization after relationship Source fixup.
|
||||
/// Calls <see cref="AddComponentImpl{T}"/> via reflection to avoid
|
||||
/// duplicating the relationship index logic.
|
||||
/// </summary>
|
||||
internal void AddComponentBoxed(Entity entity, object component, Type componentType)
|
||||
{
|
||||
var method = s_addComponentImpl.MakeGenericMethod(componentType);
|
||||
method.Invoke(this, [entity, component]);
|
||||
}
|
||||
|
||||
private static readonly System.Reflection.MethodInfo s_addComponentImpl =
|
||||
typeof(World).GetMethod(
|
||||
nameof(AddComponentImpl),
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
|
||||
private void AddComponentImpl<T>(Entity entity, T component) where T : struct
|
||||
{
|
||||
ThrowIfNotAlive(entity);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue