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 });
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
<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>
|
||||||
|
|
@ -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
|
// 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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue