Compare commits

..

7 Commits

Author SHA1 Message Date
hypercross b066ac2eba docs: update documentation and implementation plan
Update the API surface and implementation plan to reflect architectural
changes, including new iterator overloads, system design refinements,
and the introduction of a source generator.
2026-07-20 17:44:25 +08:00
hypercross 10c4caed90 test: improve blackjack play tests and logging
Refactor `RunRound` to `RunUntilDone` to allow for multi-round
simulations
until a win/loss condition is met. Added round summaries, win/loss/push
tracking, and improved log formatting to provide better visibility into
agent performance.
2026-07-20 17:37:23 +08:00
hypercross f8d0fe314c test: add play tests for Blackjack and TicTacToe
Implement play tests for Blackjack and TicTacToe that simulate games
using various AI agents (Basic Strategy, Random, Greedy) and record
the results, decisions, and reactivity into playlogs for debugging
and LLM analysis. Update documentation to reflect the purpose of
these tests.
2026-07-20 17:22:07 +08:00
hypercross 343bcd5b2e refactor: convert component structs to record structs 2026-07-20 17:15:17 +08:00
hypercross e1fb197474 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.
2026-07-20 17:05:17 +08:00
hypercross b4661e714e test: add unit tests for Blackjack and TicTacToe
- Add xUnit test projects for both games
- Implement game flow tests for Blackjack
- Implement game flow tests for TicTacToe
- Rename namespaces and projects to follow `Game.*` convention
- Add documentation for testing games
2026-07-20 16:41:10 +08:00
hypercross 7946f4bafd refactor: reorganize project structure and move examples
- Move examples (Blackjack, TicTacToe) to the root directory
- Convert example projects from Console applications to Libraries
- Add Directory.Build.props for shared build settings
- Update solution file and project references to reflect new paths
2026-07-20 16:30:02 +08:00
92 changed files with 2333 additions and 834 deletions

9
Directory.Build.props Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>12</LangVersion>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Game.Blackjack.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Game.Blackjack\Blackjack.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,266 @@
using FluentAssertions;
using Game.Blackjack;
using OECS;
using Xunit;
namespace Game.Blackjack.Tests;
public class GameFlowTests
{
[Fact]
public void NewGame_StartsInBettingPhase()
{
var (world, _) = SetupGame();
var state = world.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.Betting);
state.Chips.Should().Be(100);
state.RoundNumber.Should().Be(1);
}
[Fact]
public void PlaceBet_AdvancesToDealing()
{
var (world, group) = SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var state = world.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.PlayerTurn); // Dealing → PlayerTurn happens automatically
state.CurrentBet.Should().Be(10);
state.Chips.Should().Be(90);
}
[Fact]
public void PlaceBet_RejectsInsufficientChips()
{
var (world, group) = SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 200 });
group.RunLogical();
var state = world.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.Betting); // Should not advance
state.Chips.Should().Be(100);
}
[Fact]
public void PlaceBet_RejectsZeroOrNegative()
{
var (world, group) = SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 0 });
group.RunLogical();
world.ReadSingleton<GameState>().Phase.Should().Be(GamePhase.Betting);
world.Commands.Enqueue(new PlaceBetCommand { Amount = -5 });
group.RunLogical();
world.ReadSingleton<GameState>().Phase.Should().Be(GamePhase.Betting);
}
[Fact]
public void Dealing_CreatesDeckAndHands()
{
var (world, group) = SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
// Should have a deck entity.
var deckEntity = FindEntity<Deck>(world);
deckEntity.Should().NotBe(Entity.Null);
// Should have player and dealer hand entities.
var playerHand = FindEntity<PlayerHand>(world);
playerHand.Should().NotBe(Entity.Null);
var dealerHand = FindEntity<DealerHand>(world);
dealerHand.Should().NotBe(Entity.Null);
}
[Fact]
public void Dealing_DealsTwoCardsToEach()
{
var (world, group) = SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var playerHand = FindEntity<PlayerHand>(world);
var dealerHand = FindEntity<DealerHand>(world);
CountCardsInHand(world, playerHand).Should().Be(2);
CountCardsInHand(world, dealerHand).Should().Be(2);
}
[Fact]
public void Hit_DrawsOneCard()
{
var (world, group) = SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var playerHand = FindEntity<PlayerHand>(world);
var before = CountCardsInHand(world, playerHand);
world.Commands.Enqueue(new HitCommand());
group.RunLogical();
var after = CountCardsInHand(world, playerHand);
after.Should().Be(before + 1);
}
[Fact]
public void Stand_AdvancesToDealerTurn()
{
var (world, group) = SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
world.Commands.Enqueue(new StandCommand());
group.RunLogical();
var state = world.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.RoundOver);
}
[Fact]
public void NewRound_ResetsPhase()
{
var (world, group) = SetupGame();
// Play a round.
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
world.Commands.Enqueue(new StandCommand());
group.RunLogical();
// Start new round.
world.Commands.Enqueue(new NewRoundCommand());
group.RunLogical();
var state = world.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.Betting);
state.Result.Should().Be(RoundResult.None);
}
[Fact]
public void DeterministicSeed_ProducesSameDeal()
{
var (world1, group1) = SetupGame(seed: 42);
world1.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group1.RunLogical();
var cards1 = GetAllCards(world1);
var (world2, group2) = SetupGame(seed: 42);
world2.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group2.RunLogical();
var cards2 = GetAllCards(world2);
cards1.Should().Equal(cards2);
}
[Fact]
public void PlayerBust_LosesBet()
{
// Use a seed that produces a bust-prone hand, then hit repeatedly.
var (world, group) = SetupGame(seed: 12345);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
// Hit until bust or stand.
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn)
{
world.Commands.Enqueue(new HitCommand());
group.RunLogical();
}
// Game should have resolved.
var state = world.ReadSingleton<GameState>();
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)
{
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);
}
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;
}
private static int CountCardsInHand(World world, Entity handEntity)
{
// Holds relationship: Source = card entity, Target = hand entity.
// GetSources returns all card entities that have a Holds pointing to handEntity.
return world.GetSources<Holds>(handEntity).Count;
}
private static List<(Suit, Rank)> GetAllCards(World world)
{
var cards = new List<(Suit, Rank)>();
var query = world.Query().With<Card>().Build();
world.ForEach(query, (Entity e, ref Card card) =>
{
cards.Add((card.Suit, card.Rank));
});
cards.Sort((a, b) =>
{
int cmp = a.Item1.CompareTo(b.Item1);
return cmp != 0 ? cmp : a.Item2.CompareTo(b.Item2);
});
return cards;
}
}

View File

@ -0,0 +1,381 @@
using FluentAssertions;
using Game.Blackjack;
using OECS;
using R3;
using Xunit;
namespace Game.Blackjack.Tests;
public class PlayTests
{
private const int StartingChips = 100;
private const int BetAmount = 10;
[Fact]
public void Play_BasicStrategy()
{
var log = new PlayLog();
var (world, group) = SetupGame(seed: 42);
world.ObserveComponentChanges<Card>().Subscribe(change =>
{
var card = world.ReadComponent<Card>(change.Entity);
log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}");
});
world.ObserveComponentChanges<GameState>().Subscribe(change =>
{
var state = world.ReadComponent<GameState>(change.Entity);
log.Reactivity.Add($"{change.Kind} GameState = {state}");
});
var agent = new BasicStrategyAgent();
log.Header = $"Blackjack: Basic Strategy (seed=42, agent={agent})";
RunUntilDone(world, group, agent, log);
log.FinalSnapshot = SnapshotWorld(world);
var path = SavePlayLog("blackjack_basic_strategy.playlog", log);
ReadAndVerify(path);
}
[Fact]
public void Play_RandomAgent()
{
var log = new PlayLog();
var (world, group) = SetupGame(seed: 123);
world.ObserveComponentChanges<Card>().Subscribe(change =>
{
var card = world.ReadComponent<Card>(change.Entity);
log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}");
});
world.ObserveComponentChanges<GameState>().Subscribe(change =>
{
var state = world.ReadComponent<GameState>(change.Entity);
log.Reactivity.Add($"{change.Kind} GameState = {state}");
});
var agent = new RandomBlackjackAgent();
log.Header = $"Blackjack: Random Agent (seed=123, agent={agent})";
RunUntilDone(world, group, agent, log);
log.FinalSnapshot = SnapshotWorld(world);
var path = SavePlayLog("blackjack_random_agent.playlog", log);
ReadAndVerify(path);
}
[Fact]
public void Play_WeightedPool()
{
var log = new PlayLog();
var (world, group) = SetupGame(seed: 77);
world.ObserveComponentChanges<Card>().Subscribe(change =>
{
var card = world.ReadComponent<Card>(change.Entity);
log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}");
});
world.ObserveComponentChanges<GameState>().Subscribe(change =>
{
var state = world.ReadComponent<GameState>(change.Entity);
log.Reactivity.Add($"{change.Kind} GameState = {state}");
});
var pool = new WeightedAgentPool();
pool.Add(new BasicStrategyAgent(), 7);
pool.Add(new RandomBlackjackAgent(), 3);
var agent = pool.Pick();
log.Header = $"Blackjack: Weighted Pool (seed=77, agent={agent})";
RunUntilDone(world, group, agent, log);
log.FinalSnapshot = SnapshotWorld(world);
var path = SavePlayLog("blackjack_weighted_pool.playlog", log);
ReadAndVerify(path);
}
// ── Play Log ──────────────────────────────────────────────────────
private sealed class PlayLog
{
public string Header { get; set; } = "";
public readonly List<string> RoundSummaries = new();
public readonly List<string> Decisions = new();
public readonly List<string> Reactivity = new();
public string FinalSnapshot { get; set; } = "";
public string Build()
{
var sb = new System.Text.StringBuilder();
sb.AppendLine(Header);
sb.AppendLine(new string('=', Header.Length));
sb.AppendLine();
sb.AppendLine("--- Round Summaries ---");
for (int i = 0; i < RoundSummaries.Count; i++)
sb.AppendLine($" Round {i + 1}: {RoundSummaries[i]}");
sb.AppendLine();
sb.AppendLine("--- Decisions ---");
for (int i = 0; i < Decisions.Count; i++)
sb.AppendLine($" {i + 1}. {Decisions[i]}");
sb.AppendLine();
sb.AppendLine("--- Reactivity ---");
foreach (var entry in Reactivity)
sb.AppendLine($" {entry}");
sb.AppendLine();
sb.AppendLine("--- Final State ---");
sb.AppendLine(FinalSnapshot);
return sb.ToString();
}
}
// ── Round Runner ──────────────────────────────────────────────────
private static (World, SystemGroup) SetupGame(uint seed)
{
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 = StartingChips,
CurrentBet = 0,
RoundNumber = 1,
Seed = seed
});
world.PostChanges();
return (world, group);
}
/// <summary>
/// Runs rounds until the player runs out of chips (can't afford minimum bet)
/// or doubles their starting chips.
/// </summary>
private static void RunUntilDone(World world, SystemGroup group, IBlackjackAgent agent, PlayLog log)
{
int totalRounds = 0;
int wins = 0;
int losses = 0;
int pushes = 0;
while (true)
{
var state = world.ReadSingleton<GameState>();
int chips = state.Chips;
// Stop conditions.
if (chips < BetAmount)
{
log.Header += $" — BUSTED after {totalRounds} rounds";
break;
}
if (chips >= StartingChips * 2)
{
log.Header += $" — DOUBLED after {totalRounds} rounds";
break;
}
// Safety: max 200 rounds to prevent infinite loops.
if (totalRounds >= 200)
{
log.Header += $" — MAX ROUNDS ({totalRounds})";
break;
}
// Place bet and run dealing.
world.Commands.Enqueue(new PlaceBetCommand { Amount = BetAmount });
group.RunLogical();
// Player turn: agent decides hit/stand.
int roundHits = 0;
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn)
{
var total = HandUtil.CalculateHand(world, new PlayerHand());
var decision = agent.Decide(world);
log.Decisions.Add($"R{totalRounds + 1} Hand={total}, Decision={decision}");
if (decision == BlackjackDecision.Hit)
{
world.Commands.Enqueue(new HitCommand());
roundHits++;
}
else
{
world.Commands.Enqueue(new StandCommand());
}
group.RunLogical();
}
// Record round result.
state = world.ReadSingleton<GameState>();
int newChips = state.Chips;
int delta = newChips - chips;
string summary = $"{state.Result} | Bet={BetAmount} | Chips: {chips}→{newChips} ({delta:+0;-#}) | Hits: {roundHits}";
log.RoundSummaries.Add(summary);
switch (state.Result)
{
case RoundResult.PlayerWin:
case RoundResult.DealerBust:
wins++;
break;
case RoundResult.DealerWin:
case RoundResult.PlayerBust:
losses++;
break;
case RoundResult.Push:
pushes++;
break;
}
totalRounds++;
// Start next round.
world.Commands.Enqueue(new NewRoundCommand());
group.RunLogical();
}
log.Header += $" | W:{wins} L:{losses} P:{pushes}";
}
// ── Snapshot ──────────────────────────────────────────────────────
private static string SnapshotWorld(World world)
{
var sb = new System.Text.StringBuilder();
var state = world.ReadSingleton<GameState>();
sb.AppendLine($"Phase: {state.Phase}");
sb.AppendLine($"Chips: {state.Chips}, Bet: {state.CurrentBet}");
sb.AppendLine($"Round: {state.RoundNumber}");
sb.AppendLine($"Result: {state.Result}");
var playerHand = FindEntity<PlayerHand>(world);
if (playerHand != Entity.Null)
{
sb.AppendLine($"Player ({HandUtil.CalculateHand(world, new PlayerHand())}):");
foreach (var card in GetHandCards(world, playerHand))
sb.AppendLine($" {card}");
}
var dealerHand = FindEntity<DealerHand>(world);
if (dealerHand != Entity.Null)
{
sb.AppendLine($"Dealer ({HandUtil.CalculateHand(world, new DealerHand())}):");
foreach (var card in GetHandCards(world, dealerHand))
sb.AppendLine($" {card}");
}
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;
}
private static List<string> GetHandCards(World world, Entity hand)
{
var cards = new List<string>();
foreach (var cardEntity in world.GetSources<Holds>(hand))
{
var card = world.ReadComponent<Card>(cardEntity);
cards.Add($"{card.Rank} of {card.Suit}");
}
return cards;
}
// ── File I/O ──────────────────────────────────────────────────────
private static string SavePlayLog(string filename, PlayLog log)
{
var dir = Path.Combine(AppContext.BaseDirectory, "playlogs");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, filename);
File.WriteAllText(path, log.Build());
return path;
}
private static void ReadAndVerify(string path)
{
var content = File.ReadAllText(path);
content.Should().Contain("--- Round Summaries ---");
content.Should().Contain("--- Decisions ---");
content.Should().Contain("--- Reactivity ---");
content.Should().Contain("--- Final State ---");
content.Should().Contain("Result:");
}
// ── Agents ────────────────────────────────────────────────────────
private enum BlackjackDecision { Hit, Stand }
private interface IBlackjackAgent
{
BlackjackDecision Decide(World world);
}
private sealed class BasicStrategyAgent : IBlackjackAgent
{
public BlackjackDecision Decide(World world)
{
var total = HandUtil.CalculateHand(world, new PlayerHand());
return total < 17 ? BlackjackDecision.Hit : BlackjackDecision.Stand;
}
public override string ToString() => "BasicStrategy";
}
private sealed class RandomBlackjackAgent : IBlackjackAgent
{
private static readonly Random _rng = new();
public BlackjackDecision Decide(World world)
{
var total = HandUtil.CalculateHand(world, new PlayerHand());
if (total >= 21) return BlackjackDecision.Stand;
return _rng.Next(2) == 0 ? BlackjackDecision.Hit : BlackjackDecision.Stand;
}
public override string ToString() => "Random";
}
private sealed class WeightedAgentPool
{
private readonly List<(IBlackjackAgent Agent, int Weight)> _agents = new();
private int _totalWeight;
private static readonly Random _rng = new();
public void Add(IBlackjackAgent agent, int weight)
{
_agents.Add((agent, weight));
_totalWeight += weight;
}
public IBlackjackAgent Pick()
{
int roll = _rng.Next(_totalWeight);
int cumulative = 0;
foreach (var (agent, weight) in _agents)
{
cumulative += weight;
if (roll < cumulative)
return agent;
}
return _agents[^1].Agent;
}
}
}

View File

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

View File

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<RootNamespace>Game.Blackjack</RootNamespace>
</PropertyGroup>
<ItemGroup>
<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>
<Analyzer Include="..\OECS.SourceGen\bin\$(Configuration)\netstandard2.0\OECS.SourceGen.dll" />
</ItemGroup>
</Project>

View File

@ -1,7 +1,7 @@
using MessagePack; using MessagePack;
using OECS; using OECS;
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// Player hits: draws one card from the deck to the player's hand. /// Player hits: draws one card from the deck to the player's hand.

View File

@ -1,7 +1,7 @@
using MessagePack; using MessagePack;
using OECS; using OECS;
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// Starts a new round after the previous one ended. /// Starts a new round after the previous one ended.
@ -46,6 +46,7 @@ public struct NewRoundCommand : ICommand
} }
} }
state.RoundNumber++;
state.Phase = GamePhase.Betting; state.Phase = GamePhase.Betting;
state.Result = RoundResult.None; state.Result = RoundResult.None;
world.MarkModified<GameState>(World.SingletonEntity); world.MarkModified<GameState>(World.SingletonEntity);

View File

@ -1,7 +1,7 @@
using MessagePack; using MessagePack;
using OECS; using OECS;
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// Places a bet and advances the game to the dealing phase. /// Places a bet and advances the game to the dealing phase.

View File

@ -1,7 +1,7 @@
using MessagePack; using MessagePack;
using OECS; using OECS;
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// Player stands: advance to the dealer's turn. /// Player stands: advance to the dealer's turn.

View File

@ -1,13 +1,13 @@
using MessagePack; using MessagePack;
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// A playing card with a suit and rank. /// A playing card with a suit and rank.
/// One entity per card. /// One entity per card.
/// </summary> /// </summary>
[MessagePackObject] [MessagePackObject]
public struct Card public record struct Card
{ {
[Key(0)] public Suit Suit; [Key(0)] public Suit Suit;
[Key(1)] public Rank Rank; [Key(1)] public Rank Rank;

View File

@ -1,6 +1,6 @@
using MessagePack; using MessagePack;
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// Tag component marking the dealer's hand entity. /// Tag component marking the dealer's hand entity.

View File

@ -1,6 +1,6 @@
using MessagePack; using MessagePack;
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// Tag component marking the deck entity. /// Tag component marking the deck entity.

View File

@ -1,14 +1,14 @@
using MessagePack; using MessagePack;
using OECS; using OECS;
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// Relationship from a hand entity to a card entity, /// Relationship from a hand entity to a card entity,
/// representing that the hand holds this card. /// representing that the hand holds this card.
/// </summary> /// </summary>
[MessagePackObject] [MessagePackObject]
public struct Holds : IRelationship public record struct Holds : IRelationship
{ {
[Key(0)] public Entity Source { get; set; } [Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; } [Key(1)] public Entity Target { get; set; }

View File

@ -1,14 +1,14 @@
using MessagePack; using MessagePack;
using OECS; using OECS;
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// Relationship from a card entity to the deck entity, /// Relationship from a card entity to the deck entity,
/// representing that the card is in the deck. /// representing that the card is in the deck.
/// </summary> /// </summary>
[MessagePackObject] [MessagePackObject]
public struct InDeck : IRelationship public record struct InDeck : IRelationship
{ {
[Key(0)] public Entity Source { get; set; } [Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; } [Key(1)] public Entity Target { get; set; }

View File

@ -1,6 +1,6 @@
using MessagePack; using MessagePack;
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// Tag component marking the player's hand entity. /// Tag component marking the player's hand entity.

View File

@ -1,4 +1,4 @@
namespace Blackjack; namespace Game.Blackjack;
public enum Rank : byte public enum Rank : byte
{ {

View File

@ -1,4 +1,4 @@
namespace Blackjack; namespace Game.Blackjack;
public enum Suit : byte public enum Suit : byte
{ {

View File

@ -1,4 +1,4 @@
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator. /// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator.

View File

@ -1,4 +1,4 @@
namespace Blackjack; namespace Game.Blackjack;
public enum GamePhase : byte public enum GamePhase : byte
{ {

View File

@ -1,12 +1,12 @@
using MessagePack; using MessagePack;
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// Global game state stored on the singleton entity. /// Global game state stored on the singleton entity.
/// </summary> /// </summary>
[MessagePackObject] [MessagePackObject]
public struct GameState public record struct GameState
{ {
[Key(0)] public GamePhase Phase; [Key(0)] public GamePhase Phase;
[Key(1)] public RoundResult Result; [Key(1)] public RoundResult Result;

View File

@ -1,4 +1,4 @@
namespace Blackjack; namespace Game.Blackjack;
public enum RoundResult : byte public enum RoundResult : byte
{ {

View File

@ -1,6 +1,6 @@
using OECS; using OECS;
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// Deals initial two cards to player and dealer when entering the dealing phase. /// Deals initial two cards to player and dealer when entering the dealing phase.

View File

@ -1,6 +1,6 @@
using OECS; using OECS;
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// Dealer draws cards until reaching 17 or higher, /// Dealer draws cards until reaching 17 or higher,

View File

@ -1,6 +1,6 @@
using OECS; using OECS;
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// Creates the deck (52 cards + deck/hand entities) and shuffles /// Creates the deck (52 cards + deck/hand entities) and shuffles

View File

@ -1,6 +1,6 @@
using OECS; using OECS;
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// Helper to calculate the blackjack value of a hand. /// Helper to calculate the blackjack value of a hand.

View File

@ -1,6 +1,6 @@
using OECS; using OECS;
namespace Blackjack; namespace Game.Blackjack;
/// <summary> /// <summary>
/// Evaluates the player's hand total after each hit. /// Evaluates the player's hand total after each hit.

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Game.TicTacToe.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Game.TicTacToe\TicTacToe.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,213 @@
using FluentAssertions;
using Game.TicTacToe;
using OECS;
using Xunit;
namespace Game.TicTacToe.Tests;
public class GameFlowTests
{
[Fact]
public void NewGame_HasEmptyBoard()
{
var (world, _) = SetupGame();
var query = world.Query().With<Cell>().Without<Mark>().Build();
var emptyCount = 0;
world.ForEach(query, (Entity e, ref Cell cell) => emptyCount++);
emptyCount.Should().Be(9);
}
[Fact]
public void PlaceMark_ClaimsCell()
{
var (world, group) = SetupGame();
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 });
group.RunLogical();
var query = world.Query().With<Cell>().With<Mark>().Build();
var markedCount = 0;
world.ForEach(query, (Entity e, ref Cell cell, ref Mark mark) =>
{
markedCount++;
cell.Row.Should().Be(0);
cell.Col.Should().Be(0);
mark.Player.Should().Be(Player.X);
});
markedCount.Should().Be(1);
}
[Fact]
public void PlaceMark_TogglesPlayer()
{
var (world, group) = SetupGame();
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); // X
group.RunLogical();
world.ReadSingleton<GameState>().CurrentPlayer.Should().Be(Player.O);
world.Commands.Enqueue(new PlaceMarkCommand { Row = 1, Col = 0 }); // O
group.RunLogical();
world.ReadSingleton<GameState>().CurrentPlayer.Should().Be(Player.X);
}
[Fact]
public void PlaceMark_CannotOverwriteClaimedCell()
{
var (world, group) = SetupGame();
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); // X
group.RunLogical();
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); // O tries same cell
group.RunLogical();
// Only one mark should exist at (0,0), and it should still be X.
var query = world.Query().With<Cell>().With<Mark>().Build();
var marks = new List<(int Row, int Col, Player Player)>();
world.ForEach(query, (Entity e, ref Cell cell, ref Mark mark) =>
{
marks.Add((cell.Row, cell.Col, mark.Player));
});
marks.Should().ContainSingle()
.Which.Should().Be((0, 0, Player.X));
}
[Fact]
public void XWins_Row()
{
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));
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.XWon);
}
[Fact]
public void OWins_Column()
{
var (world, group) = SetupGame();
// X: (0,0), O: (1,0), X: (0,1), O: (1,1), X: (2,2), O: (1,2) → O wins col 0
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (2, 2), (1, 2));
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.OWon);
}
[Fact]
public void XWins_Diagonal()
{
var (world, group) = SetupGame();
// X: (0,0), O: (1,0), X: (1,1), O: (1,2), X: (2,2) → X wins diagonal
PlayMoves(world, group, (0, 0), (1, 0), (1, 1), (1, 2), (2, 2));
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.XWon);
}
[Fact]
public void Draw_AllCellsFilled()
{
var (world, group) = SetupGame();
// Fill all 9 cells without a winner.
// X: (0,0) (0,2) (1,0) (2,1) (2,2)
// O: (0,1) (1,1) (1,2) (2,0)
PlayMoves(world, group,
(0, 0), (0, 1), (0, 2),
(1, 1), (1, 0), (1, 2),
(2, 1), (2, 0), (2, 2));
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.Draw);
}
[Fact]
public void MovesAfterGameOver_AreIgnored()
{
var (world, group) = SetupGame();
// X wins.
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.XWon);
// Try to place another mark — should be ignored.
var moveCountBefore = world.ReadSingleton<GameState>().MoveCount;
world.Commands.Enqueue(new PlaceMarkCommand { Row = 2, Col = 2 });
group.RunLogical();
world.ReadSingleton<GameState>().MoveCount.Should().Be(moveCountBefore);
}
[Fact]
public void SaveLoad_RoundTrips()
{
var (world, group) = SetupGame();
// Play a few moves.
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 });
group.RunLogical();
world.Commands.Enqueue(new PlaceMarkCommand { Row = 1, Col = 0 });
group.RunLogical();
// Save.
using var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
// Load into a new world.
var world2 = new World();
WorldSerializer.Load(world2, stream);
// Verify state.
var state = world2.ReadSingleton<GameState>();
state.CurrentPlayer.Should().Be(Player.X);
state.MoveCount.Should().Be(2);
var query = world2.Query().With<Cell>().With<Mark>().Build();
var marks = new List<(int Row, int Col, Player Player)>();
world2.ForEach(query, (Entity e, ref Cell cell, ref Mark mark) =>
{
marks.Add((cell.Row, cell.Col, mark.Player));
});
marks.Should().BeEquivalentTo([(0, 0, Player.X), (1, 0, Player.O)]);
}
// ── Helpers ───────────────────────────────────────────────────────
private static (World, SystemGroup) SetupGame()
{
var world = new World();
var group = new SystemGroup(world);
group.Add(new WinCheckSystem());
// Create the 9 cells.
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();
}
}
}

View File

@ -0,0 +1,284 @@
using FluentAssertions;
using Game.TicTacToe;
using OECS;
using R3;
using Xunit;
namespace Game.TicTacToe.Tests;
public class PlayTests
{
[Fact]
public void Play_GreedyVsRandom()
{
var log = new PlayLog();
var (world, group) = SetupGame();
var agentX = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock);
var agentO = new RandomAgent();
// Subscribe reactivity.
world.ObserveComponentChanges<Mark>().Subscribe(change =>
{
var mark = world.ReadComponent<Mark>(change.Entity);
log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}");
});
log.Header = "TicTacToe: Greedy X vs Random O";
RunGame(world, group, agentX, agentO, log);
log.FinalSnapshot = SnapshotWorld(world);
var path = SavePlayLog("tic_tac_toe_greedy_vs_random.playlog", log);
ReadAndVerify(path);
}
[Fact]
public void Play_RandomVsRandom()
{
var log = new PlayLog();
var (world, group) = SetupGame();
var agentX = new RandomAgent();
var agentO = new RandomAgent();
world.ObserveComponentChanges<Mark>().Subscribe(change =>
{
var mark = world.ReadComponent<Mark>(change.Entity);
log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}");
});
log.Header = "TicTacToe: Random X vs Random O";
RunGame(world, group, agentX, agentO, log);
log.FinalSnapshot = SnapshotWorld(world);
var path = SavePlayLog("tic_tac_toe_random_vs_random.playlog", log);
ReadAndVerify(path);
}
[Fact]
public void Play_GreedyVsGreedy()
{
var log = new PlayLog();
var (world, group) = SetupGame();
var agentX = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock);
var agentO = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock);
world.ObserveComponentChanges<Mark>().Subscribe(change =>
{
var mark = world.ReadComponent<Mark>(change.Entity);
log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}");
});
log.Header = "TicTacToe: Greedy X vs Greedy O";
RunGame(world, group, agentX, agentO, log);
log.FinalSnapshot = SnapshotWorld(world);
var path = SavePlayLog("tic_tac_toe_greedy_vs_greedy.playlog", log);
ReadAndVerify(path);
}
// ── Play Log ──────────────────────────────────────────────────────
private sealed class PlayLog
{
public string Header { get; set; } = "";
public readonly List<string> Moves = new();
public readonly List<string> Reactivity = new();
public string FinalSnapshot { get; set; } = "";
public string Build()
{
var sb = new System.Text.StringBuilder();
sb.AppendLine(Header);
sb.AppendLine(new string('=', Header.Length));
sb.AppendLine();
sb.AppendLine("--- Moves ---");
for (int i = 0; i < Moves.Count; i++)
sb.AppendLine($" {i + 1}. {Moves[i]}");
sb.AppendLine();
sb.AppendLine("--- Reactivity ---");
foreach (var entry in Reactivity)
sb.AppendLine($" {entry}");
sb.AppendLine();
sb.AppendLine("--- Final Board ---");
sb.AppendLine(FinalSnapshot);
return sb.ToString();
}
}
// ── Game Runner ───────────────────────────────────────────────────
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 RunGame(World world, SystemGroup group, IAgent agentX, IAgent agentO, PlayLog log)
{
while (world.ReadSingleton<GameState>().Status == GameStatus.Playing)
{
var state = world.ReadSingleton<GameState>();
var agent = state.CurrentPlayer == Player.X ? agentX : agentO;
var cmd = agent.Decide(world);
world.Commands.Enqueue(cmd);
group.RunLogical();
log.Moves.Add($"{state.CurrentPlayer} → ({cmd.Row},{cmd.Col})");
}
var final = world.ReadSingleton<GameState>();
log.Header += $" — Result: {final.Status}";
}
// ── Snapshot ──────────────────────────────────────────────────────
private static string SnapshotWorld(World world)
{
var sb = new System.Text.StringBuilder();
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';
}
for (int r = 0; r < 3; r++)
{
sb.Append(" ");
for (int c = 0; c < 3; c++)
sb.Append(grid[r, c]?.ToString() ?? ".");
sb.AppendLine();
}
return sb.ToString().TrimEnd();
}
// ── File I/O ──────────────────────────────────────────────────────
private static string SavePlayLog(string filename, PlayLog log)
{
var dir = Path.Combine(AppContext.BaseDirectory, "playlogs");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, filename);
File.WriteAllText(path, log.Build());
return path;
}
private static void ReadAndVerify(string path)
{
var content = File.ReadAllText(path);
// Verify the play log is well-formed.
content.Should().Contain("--- Moves ---");
content.Should().Contain("--- Reactivity ---");
content.Should().Contain("--- Final Board ---");
// The game must have finished.
content.Should().Contain("Result:");
content.Should().NotContain("Result: Playing");
}
// ── Agents ────────────────────────────────────────────────────────
private interface IAgent
{
PlaceMarkCommand Decide(World world);
}
private sealed class RandomAgent : IAgent
{
private static readonly Random _rng = new();
public PlaceMarkCommand Decide(World world)
{
var empty = GetEmptyCells(world);
var (row, col) = empty[_rng.Next(empty.Count)];
return new PlaceMarkCommand { Row = row, Col = col };
}
}
private sealed class GreedyAgent : IAgent
{
public enum Strategy { WinOrBlock }
private readonly Strategy _strategy;
private static readonly Random _rng = new();
public GreedyAgent(Strategy strategy) => _strategy = strategy;
public PlaceMarkCommand Decide(World world)
{
var state = world.ReadSingleton<GameState>();
var me = state.CurrentPlayer;
var empty = GetEmptyCells(world);
foreach (var (r, c) in empty)
if (WouldWin(world, r, c, me))
return new PlaceMarkCommand { Row = r, Col = c };
var opponent = me == Player.X ? Player.O : Player.X;
foreach (var (r, c) in empty)
if (WouldWin(world, r, c, opponent))
return new PlaceMarkCommand { Row = r, Col = c };
if (empty.Any(c => c.Row == 1 && c.Col == 1))
return new PlaceMarkCommand { Row = 1, Col = 1 };
var (rr, cc) = empty[_rng.Next(empty.Count)];
return new PlaceMarkCommand { Row = rr, Col = cc };
}
}
private static List<(int Row, int Col)> GetEmptyCells(World world)
{
var empty = new List<(int, int)>();
var query = world.Query().With<Cell>().Without<Mark>().Build();
using var iter = world.Select<Cell>(query);
while (iter.MoveNext())
empty.Add((iter.Current1.Row, iter.Current1.Col));
return empty;
}
private static bool WouldWin(World world, int row, int col, Player player)
{
var grid = new Player[3, 3];
using (var iter = world.Select<Cell, Mark>())
while (iter.MoveNext())
grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player;
grid[row, col] = player;
for (int r = 0; r < 3; r++)
if (grid[r, 0] == player && grid[r, 1] == player && grid[r, 2] == player)
return true;
for (int c = 0; c < 3; c++)
if (grid[0, c] == player && grid[1, c] == player && grid[2, c] == player)
return true;
if (grid[0, 0] == player && grid[1, 1] == player && grid[2, 2] == player)
return true;
if (grid[0, 2] == player && grid[1, 1] == player && grid[2, 0] == player)
return true;
return false;
}
}

View File

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

View File

@ -1,7 +1,7 @@
using MessagePack; using MessagePack;
using OECS; using OECS;
namespace TicTacToe; namespace Game.TicTacToe;
/// <summary> /// <summary>
/// Places the current player's mark on the cell at (Row, Col). /// Places the current player's mark on the cell at (Row, Col).

View File

@ -1,12 +1,12 @@
using MessagePack; using MessagePack;
namespace TicTacToe; namespace Game.TicTacToe;
/// <summary> /// <summary>
/// Identifies a board position. One entity per cell. /// Identifies a board position. One entity per cell.
/// </summary> /// </summary>
[MessagePackObject] [MessagePackObject]
public struct Cell public record struct Cell
{ {
[Key(0)] public int Row; [Key(0)] public int Row;
[Key(1)] public int Col; [Key(1)] public int Col;

View File

@ -1,13 +1,13 @@
using MessagePack; using MessagePack;
namespace TicTacToe; namespace Game.TicTacToe;
/// <summary> /// <summary>
/// A mark placed on a cell by a player. /// A mark placed on a cell by a player.
/// Only present on cells that have been claimed. /// Only present on cells that have been claimed.
/// </summary> /// </summary>
[MessagePackObject] [MessagePackObject]
public struct Mark public record struct Mark
{ {
[Key(0)] public Player Player; [Key(0)] public Player Player;
} }

View File

@ -1,4 +1,4 @@
namespace TicTacToe; namespace Game.TicTacToe;
public enum Player : byte public enum Player : byte
{ {

View File

@ -1,6 +1,6 @@
using OECS; using OECS;
namespace TicTacToe; namespace Game.TicTacToe;
/// <summary> /// <summary>
/// Minimal CSV loader that creates entities from a CSV file. /// Minimal CSV loader that creates entities from a CSV file.

View File

@ -1,12 +1,12 @@
using MessagePack; using MessagePack;
namespace TicTacToe; namespace Game.TicTacToe;
/// <summary> /// <summary>
/// Global game state stored on the singleton entity. /// Global game state stored on the singleton entity.
/// </summary> /// </summary>
[MessagePackObject] [MessagePackObject]
public struct GameState public record struct GameState
{ {
[Key(0)] public Player CurrentPlayer; [Key(0)] public Player CurrentPlayer;
[Key(1)] public GameStatus Status; [Key(1)] public GameStatus Status;

View File

@ -1,4 +1,4 @@
namespace TicTacToe; namespace Game.TicTacToe;
public enum GameStatus : byte public enum GameStatus : byte
{ {

View File

@ -1,6 +1,6 @@
using OECS; using OECS;
namespace TicTacToe; namespace Game.TicTacToe;
/// <summary> /// <summary>
/// After each move, checks whether the game has been won or drawn. /// After each move, checks whether the game has been won or drawn.

View File

@ -1,19 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Library</OutputType>
<TargetFramework>net8.0</TargetFramework> <RootNamespace>Game.TicTacToe</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>TicTacToe</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\OECS\OECS.csproj" /> <ProjectReference Include="..\OECS\OECS.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\OECS.SourceGen\OECS.SourceGen.csproj" <ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" /> OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup> </ItemGroup>

View File

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

View File

@ -2,9 +2,6 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework> <TargetFramework>netstandard2.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>12</LangVersion>
<RootNamespace>OECS.SourceGen</RootNamespace> <RootNamespace>OECS.SourceGen</RootNamespace>
<AssemblyName>OECS.SourceGen</AssemblyName> <AssemblyName>OECS.SourceGen</AssemblyName>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>

View File

@ -1,9 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>OECS.Tests</RootNamespace> <RootNamespace>OECS.Tests</RootNamespace>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
@ -20,11 +17,11 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\OECS\OECS.csproj" /> <ProjectReference Include="..\OECS\OECS.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\OECS.SourceGen\OECS.SourceGen.csproj" <ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" /> OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup> </ItemGroup>

View File

@ -1,19 +1,20 @@
 Microsoft Visual Studio Solution File, Format Version 12.00
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59 VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.SourceGen", "src\OECS.SourceGen\OECS.SourceGen.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS", "OECS\OECS.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS", "src\OECS\OECS.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.SourceGen", "OECS.SourceGen\OECS.SourceGen.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.Tests", "tests\OECS.Tests\OECS.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.Tests", "OECS.Tests\OECS.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Blackjack", "Game.Blackjack\Blackjack.csproj", "{85631EDE-F984-4DA6-8EE9-0715AF1204E6}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blackjack", "examples\Blackjack\Blackjack.csproj", "{85631EDE-F984-4DA6-8EE9-0715AF1204E6}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.TicTacToe", "Game.TicTacToe\TicTacToe.csproj", "{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Blackjack.Tests", "Game.Blackjack.Tests\Game.Blackjack.Tests.csproj", "{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.TicTacToe.Tests", "Game.TicTacToe.Tests\Game.TicTacToe.Tests.csproj", "{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -25,18 +26,6 @@ Global
Release|x86 = Release|x86 Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.ActiveCfg = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.Build.0 = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.ActiveCfg = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.Build.0 = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.ActiveCfg = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.Build.0 = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.ActiveCfg = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU
@ -49,6 +38,18 @@ Global
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.ActiveCfg = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.Build.0 = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.ActiveCfg = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.Build.0 = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.ActiveCfg = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.Build.0 = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.ActiveCfg = Release|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.Build.0 = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.ActiveCfg = Debug|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.ActiveCfg = Debug|Any CPU
@ -73,12 +74,45 @@ Global
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x64.Build.0 = Release|Any CPU {85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x64.Build.0 = Release|Any CPU
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x86.ActiveCfg = Release|Any CPU {85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x86.ActiveCfg = Release|Any CPU
{85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x86.Build.0 = Release|Any CPU {85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x86.Build.0 = Release|Any CPU
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Debug|x64.ActiveCfg = Debug|Any CPU
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Debug|x64.Build.0 = Debug|Any CPU
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Debug|x86.ActiveCfg = Debug|Any CPU
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Debug|x86.Build.0 = Debug|Any CPU
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|Any CPU.Build.0 = Release|Any CPU
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x64.ActiveCfg = Release|Any CPU
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x64.Build.0 = Release|Any CPU
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x86.ActiveCfg = Release|Any CPU
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.ActiveCfg = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.Build.0 = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.ActiveCfg = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.Build.0 = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.Build.0 = Release|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.ActiveCfg = Release|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.Build.0 = Release|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.ActiveCfg = Release|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.Build.0 = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.ActiveCfg = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.Build.0 = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.ActiveCfg = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.Build.0 = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.Build.0 = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.ActiveCfg = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.Build.0 = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.ActiveCfg = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{C3D4E5F6-A7B8-9012-CDEF-123456789012} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{85631EDE-F984-4DA6-8EE9-0715AF1204E6} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}
EndGlobalSection
EndGlobal EndGlobal

View File

@ -28,15 +28,23 @@ public sealed class ComponentDescriptor
/// </summary> /// </summary>
public Action<World, Entity, byte[]> DeserializeAndAdd { get; } public Action<World, Entity, byte[]> DeserializeAndAdd { get; }
/// <summary>
/// Deserializes a MessagePack byte array to a boxed component object.
/// Used by <see cref="WorldSerializer"/> for relationship fixup before adding.
/// </summary>
public Func<byte[], object> Deserialize { get; }
public ComponentDescriptor( public ComponentDescriptor(
string typeName, string typeName,
Type type, Type type,
Func<object, byte[]> serialize, Func<object, byte[]> serialize,
Action<World, Entity, byte[]> deserializeAndAdd) Action<World, Entity, byte[]> deserializeAndAdd,
Func<byte[], object> deserialize)
{ {
TypeName = typeName; TypeName = typeName;
Type = type; Type = type;
Serialize = serialize; Serialize = serialize;
DeserializeAndAdd = deserializeAndAdd; DeserializeAndAdd = deserializeAndAdd;
Deserialize = deserialize;
} }
} }

136
OECS/ComponentRegistry.cs Normal file
View File

@ -0,0 +1,136 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using MessagePack;
namespace OECS;
/// <summary>
/// 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.
/// </summary>
public static ComponentDescriptor[] Descriptors
{
get
{
EnsureScanned();
return _descriptors ?? Array.Empty<ComponentDescriptor>();
}
}
/// <summary>
/// Lookup by assembly-qualified type name.
/// </summary>
public static IReadOnlyDictionary<string, ComponentDescriptor> ByTypeName
{
get
{
EnsureScanned();
if (_byTypeName == null)
{
var dict = new Dictionary<string, ComponentDescriptor>();
foreach (var desc in Descriptors)
dict[desc.TypeName] = desc;
_byTypeName = dict;
}
return _byTypeName;
}
}
/// <summary>
/// Called by generated code to register discovered component types.
/// Multiple assemblies may call this. Descriptors are accumulated.
/// </summary>
public static void Register(ComponentDescriptor[] descriptors)
{
if (_descriptors == null)
{
_descriptors = descriptors;
}
else
{
var existing = new HashSet<string>(_descriptors.Select(d => d.TypeName));
var merged = new List<ComponentDescriptor>(_descriptors);
foreach (var d in descriptors)
{
if (!existing.Contains(d.TypeName))
{
existing.Add(d.TypeName);
merged.Add(d);
}
}
_descriptors = merged.ToArray();
}
_byTypeName = null;
}
private static void EnsureScanned()
{
if (_scanned) return;
_scanned = true;
// Runtime fallback: scan loaded assemblies for [MessagePackObject] structs.
// This ensures serialization works even when the source generator
// doesn't run (e.g., in test projects referencing game DLLs).
var scanned = new List<ComponentDescriptor>(_descriptors ?? Array.Empty<ComponentDescriptor>());
var seen = new HashSet<string>(scanned.Select(d => d.TypeName));
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
foreach (var type in asm.GetTypes())
{
if (!type.IsValueType || type.IsAbstract || !type.IsPublic)
continue;
if (type.GetCustomAttribute<MessagePackObjectAttribute>() == null)
continue;
var aqn = $"{type.FullName}, {type.Assembly.GetName().Name}";
if (!seen.Add(aqn))
continue;
var desc = CreateDescriptor(type, aqn);
scanned.Add(desc);
}
}
catch
{
// Some assemblies may throw during reflection (e.g., mixed-mode).
}
}
_descriptors = scanned.ToArray();
}
private static ComponentDescriptor CreateDescriptor(Type type, string aqn)
{
// Build serialize/deserialize delegates via reflection.
var method = typeof(ComponentRegistry).GetMethod(
nameof(CreateTypedDescriptor),
BindingFlags.NonPublic | BindingFlags.Static)!;
var generic = method.MakeGenericMethod(type);
return (ComponentDescriptor)generic.Invoke(null, [aqn])!;
}
private static ComponentDescriptor CreateTypedDescriptor<T>(string aqn) where T : struct
{
return new ComponentDescriptor(
typeName: aqn,
type: typeof(T),
serialize: obj => MessagePackSerializer.Serialize((T)obj),
deserializeAndAdd: (world, entity, data) =>
world.AddComponent(entity, MessagePackSerializer.Deserialize<T>(data)),
deserialize: data => MessagePackSerializer.Deserialize<T>(data));
}
}

View File

@ -109,21 +109,4 @@ internal class ComponentStore
return set; return set;
} }
/// <summary>
/// Returns the count of entities in the sparse set for type <typeparamref name="T"/>.
/// Returns 0 if the set does not exist.
/// </summary>
public int Count<T>() where T : struct
{
return GetSetIfExists<T>()?.Count ?? 0;
}
/// <summary>
/// Returns the count of entities in the sparse set for the given type.
/// Returns 0 if the set does not exist.
/// </summary>
public int Count(Type componentType)
{
return GetSet(componentType)?.Count ?? 0;
}
} }

View File

@ -1,9 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>OECS</RootNamespace> <RootNamespace>OECS</RootNamespace>
<AssemblyName>OECS</AssemblyName> <AssemblyName>OECS</AssemblyName>

View File

@ -70,24 +70,6 @@ internal class RelationshipIndex
return sources; return sources;
} }
/// <summary>
/// Returns all target entities that the given source entity points to
/// via the specified relationship type. Returns an empty collection if none.
/// </summary>
public IReadOnlyCollection<Entity> GetTargets(Type relationshipType, Entity source)
{
if (!_index.TryGetValue(relationshipType, out var targetMap))
return Array.Empty<Entity>();
var result = new List<Entity>();
foreach (var (target, sources) in targetMap)
{
if (sources.Contains(source))
result.Add(target);
}
return result;
}
/// <summary> /// <summary>
/// Removes all index entries where the given entity appears as a source. /// Removes all index entries where the given entity appears as a source.
/// Called before the entity's components are removed during destruction. /// Called before the entity's components are removed during destruction.

View File

@ -132,29 +132,6 @@ internal class SparseSet<T> : ISparseSet where T : struct
return ref _dense[index]; return ref _dense[index];
} }
/// <summary>
/// Tries to get the component value for the given entity.
/// Returns true and copies the value to <paramref name="value"/> if present.
/// </summary>
public bool TryGet(Entity entity, out T value)
{
if (entity.Id >= (uint)_sparse.Length)
{
value = default;
return false;
}
int index = _sparse[entity.Id];
if (index == -1)
{
value = default;
return false;
}
value = _dense[index];
return true;
}
private static void ThrowEntityNotPresent(Entity entity) private static void ThrowEntityNotPresent(Entity entity)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(

View File

@ -181,6 +181,23 @@ public class World : IDisposable
AddComponentImpl(entity, component); AddComponentImpl(entity, component);
} }
/// <summary>
/// Adds a boxed component to the entity. Used by <see cref="WorldSerializer"/>
/// during deserialization after relationship Source fixup.
/// Calls <see cref="AddComponentImpl{T}"/> via reflection to avoid
/// duplicating the relationship index logic.
/// </summary>
internal void AddComponentBoxed(Entity entity, object component, Type componentType)
{
var method = s_addComponentImpl.MakeGenericMethod(componentType);
method.Invoke(this, [entity, component]);
}
private static readonly System.Reflection.MethodInfo s_addComponentImpl =
typeof(World).GetMethod(
nameof(AddComponentImpl),
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
private void AddComponentImpl<T>(Entity entity, T component) where T : struct private void AddComponentImpl<T>(Entity entity, T component) where T : struct
{ {
ThrowIfNotAlive(entity); ThrowIfNotAlive(entity);

View File

@ -1,3 +1,4 @@
using System.Reflection;
using MessagePack; using MessagePack;
namespace OECS; namespace OECS;
@ -93,9 +94,32 @@ public static class WorldSerializer
$"Ensure the component type is used with the World " + $"Ensure the component type is used with the World " +
$"API so the source generator can discover it."); $"API so the source generator can discover it.");
// Deserialize and add in one typed operation — no reflection. // Deserialize and fix up IRelationship.Source before adding.
desc.DeserializeAndAdd(world, entity, ce.Data); var component = desc.Deserialize(ce.Data);
if (component is IRelationship rel)
FixupRelationshipSource(rel, entity);
// Add via the typed internal method — no reflection for the add itself.
world.AddComponentBoxed(entity, component, desc.Type);
} }
} }
} }
/// <summary>
/// Sets the Source field on a deserialized IRelationship to match
/// the entity it's being restored to. Uses a small cache of PropertyInfo
/// to avoid repeated reflection lookups.
/// </summary>
private static void FixupRelationshipSource(IRelationship rel, Entity entity)
{
var type = rel.GetType();
if (!_sourcePropCache.TryGetValue(type, out var prop))
{
prop = type.GetProperty("Source");
_sourcePropCache[type] = prop;
}
prop?.SetValue(rel, entity);
}
private static readonly Dictionary<Type, PropertyInfo?> _sourcePropCache = new();
} }

View File

@ -139,13 +139,17 @@ public delegate void ForEachAction<T1, T2, T3, T4, T5, T6>(Entity entity, ref T1
Extension methods on `World` for `foreach`-style iteration over queries. Extension methods on `World` for `foreach`-style iteration over queries.
Returns `ref struct` iterators that support zero-allocation iteration with Returns `ref struct` iterators that support zero-allocation iteration with
`ref` access to components. Supports 13 component types. `ref` access to components.
Two sets of overloads: one accepting an explicit `QueryDescriptor` for
filtering with `Without<T>`, and one without for simple "has component" scans.
```csharp ```csharp
namespace OECS; namespace OECS;
public static class EntityIterator public static class EntityIterator
{ {
// With explicit QueryDescriptor (supports Without<T> filters).
public static Select1<T1> Select<T1>( public static Select1<T1> Select<T1>(
this World world, QueryDescriptor query) where T1 : struct; this World world, QueryDescriptor query) where T1 : struct;
@ -156,16 +160,38 @@ public static class EntityIterator
public static Select3<T1, T2, T3> Select<T1, T2, T3>( public static Select3<T1, T2, T3> Select<T1, T2, T3>(
this World world, QueryDescriptor query) this World world, QueryDescriptor query)
where T1 : struct where T2 : struct where T3 : struct; where T1 : struct where T2 : struct where T3 : struct;
// Without QueryDescriptor — iterates all entities with the given component(s).
public static Select1<T1> Select<T1>(
this World world) where T1 : struct;
public static Select2<T1, T2> Select<T1, T2>(
this World world)
where T1 : struct where T2 : struct;
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
this World world)
where T1 : struct where T2 : struct where T3 : struct;
} }
``` ```
Usage: The singletons `CurrentEntity`, `Current1`, `Current2`, `Current3` are
exposed on the ref struct iterators directly. Usage:
```csharp ```csharp
using var iter = world.Select<Position, Velocity>(query); // Simple scan: all entities with PlayerHand.
foreach (ref var item in iter) using var iter = world.Select<PlayerHand>();
while (iter.MoveNext())
{ {
item.Current1.X += item.Current2.X * dt; Console.WriteLine(iter.CurrentEntity);
}
// With query filter:
var query = world.Query().With<Position>().Without<Frozen>().Build();
using var iter2 = world.Select<Position>(query);
while (iter2.MoveNext())
{
iter2.Current1.X += 1;
} }
``` ```
@ -202,20 +228,32 @@ public class QueryDescriptor
## ISystem ## ISystem
A system is a unit of logic that runs during a tick. It receives the `World`
and decides what to do — typically reading singletons, iterating queries,
or enqueuing commands.
```csharp ```csharp
namespace OECS; namespace OECS;
public interface ISystem public interface ISystem
{ {
QueryDescriptor Query { get; }
void Run(World world); void Run(World world);
} }
``` ```
Systems do **not** declare a query on the interface. Instead, they either
read singletons directly (`world.ReadSingleton<T>()`) or use the iterator /
`ForEach` API with a `QueryDescriptor` they build internally. This keeps
the interface minimal and gives systems full flexibility over what they
inspect at runtime.
--- ---
## ITickedSystem ## ITickedSystem
An optional extension for systems that need tick metadata (delta time or
logical tick marker).
```csharp ```csharp
namespace OECS; namespace OECS;
@ -225,6 +263,10 @@ public interface ITickedSystem : ISystem
} }
``` ```
`SystemGroup` checks each system at runtime: if it implements
`ITickedSystem`, the `Run(World, Tick)` overload is called; otherwise
`Run(World)` is called. Both overloads must be implemented.
--- ---
## Tick ## Tick
@ -266,6 +308,9 @@ public class SystemGroup
} }
``` ```
Systems execute in registration order. `SystemGroup` automatically drains
commands and posts changes after each system and after the full tick.
--- ---
## ICommand ## ICommand
@ -279,6 +324,9 @@ public interface ICommand
} }
``` ```
Implementations should be `[MessagePackObject]` structs so they can be
serialized and replayed.
--- ---
## CommandQueue ## CommandQueue
@ -297,6 +345,9 @@ public class CommandQueue
} }
``` ```
`Enqueue<T>` uses a constrained generic to avoid boxing at the call site.
Commands enqueued during `ExecuteAll` are processed in the same drain cycle.
--- ---
## IRelationship ## IRelationship
@ -324,6 +375,7 @@ namespace OECS;
[MessagePackObject] [MessagePackObject]
public struct Relationship<TSelf, TTarget> : IRelationship public struct Relationship<TSelf, TTarget> : IRelationship
where TSelf : struct where TTarget : struct
{ {
[Key(0)] public Entity Source { get; set; } [Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; } [Key(1)] public Entity Target { get; set; }
@ -359,7 +411,7 @@ public enum ChangeKind
## WorldSerializer ## WorldSerializer
Saves and loads a `World` to/from a MessagePack stream. Components are Saves and loads a `World` to/from a MessagePack stream. Components are
serialized by their runtime type. serialized by their runtime type using the source-generated `ComponentRegistry`.
```csharp ```csharp
namespace OECS; namespace OECS;
@ -393,6 +445,7 @@ public class EntitySnapshot
[Key(0)] public uint Id { get; set; } [Key(0)] public uint Id { get; set; }
[Key(1)] public uint Version { get; set; } [Key(1)] public uint Version { get; set; }
[Key(2)] public ComponentEntry[] Components { get; set; } [Key(2)] public ComponentEntry[] Components { get; set; }
[IgnoreMember]
public Entity Entity { get; } public Entity Entity { get; }
} }
@ -410,37 +463,30 @@ public class ComponentEntry
```csharp ```csharp
using OECS; using OECS;
using R3;
// Define components // Define components
[MessagePackObject] [MessagePackObject]
public struct Position : IMessagePackSerializationCallbackReceiver public struct Position
{ {
[Key(0)] public float X; [Key(0)] public float X;
[Key(1)] public float Y; [Key(1)] public float Y;
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
} }
[MessagePackObject] [MessagePackObject]
public struct Velocity : IMessagePackSerializationCallbackReceiver public struct Velocity
{ {
[Key(0)] public float X; [Key(0)] public float X;
[Key(1)] public float Y; [Key(1)] public float Y;
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
} }
// Define a system // Define a system
public class MovementSystem : ITickedSystem public class MovementSystem : ITickedSystem
{ {
public QueryDescriptor Query { get; } private readonly QueryDescriptor _query;
public MovementSystem(World world) public MovementSystem(World world)
{ {
Query = world.Query() _query = world.Query()
.With<Position>() .With<Position>()
.With<Velocity>() .With<Velocity>()
.Build(); .Build();
@ -452,7 +498,7 @@ public class MovementSystem : ITickedSystem
{ {
float dt = tick.DeltaTime; float dt = tick.DeltaTime;
world.ForEach(Query, (Entity entity, ref Position pos, ref Velocity vel) => world.ForEach(_query, (Entity entity, ref Position pos, ref Velocity vel) =>
{ {
pos.X += vel.X * dt; pos.X += vel.X * dt;
pos.Y += vel.Y * dt; pos.Y += vel.Y * dt;
@ -466,16 +512,15 @@ var world = new World();
var group = new SystemGroup(world); var group = new SystemGroup(world);
group.Add(new MovementSystem(world)); group.Add(new MovementSystem(world));
// Observe changes
world.ObserveComponentChanges<Position>()
.Subscribe(change => Console.WriteLine($"{change.Entity} moved"))
.AddTo(disposables);
// Create entities // Create entities
var player = world.CreateEntity(); var player = world.CreateEntity();
world.AddComponent(player, new Position { X = 0, Y = 0 }); world.AddComponent(player, new Position { X = 0, Y = 0 });
world.AddComponent(player, new Velocity { X = 1, Y = 0 }); world.AddComponent(player, new Velocity { X = 1, Y = 0 });
// Observe changes via R3
world.ObserveComponentChanges<Position>()
.Subscribe(change => Console.WriteLine($"{change.Entity} moved"));
// Run a tick // Run a tick
group.RunTimed(0.016f); // ~60 FPS group.RunTimed(0.016f); // ~60 FPS
``` ```
@ -494,4 +539,6 @@ These types are implementation details and may change without notice:
| `ChangeSet` | Deduplicated change accumulator within a single batch. | | `ChangeSet` | Deduplicated change accumulator within a single batch. |
| `RelationshipIndex` | Reverse lookup from target entity to source entities. | | `RelationshipIndex` | Reverse lookup from target entity to source entities. |
| `EntityAllocator` | Free-list + bump allocator for entity IDs. | | `EntityAllocator` | Free-list + bump allocator for entity IDs. |
| `QueryExecutor` | Query iteration logic with smallest-set driver optimization. | | `QueryExecutor` | Query iteration logic with smallest-set driver optimization. |
| `ComponentRegistry` | Source-generated registry of all component types for serialization. |
| `ComponentDescriptor` | Source-generated per-type descriptor with serialize/deserialize callbacks. |

View File

@ -8,9 +8,10 @@ ones. Architecture decisions are captured in `docs/architecture.md`.
## Target ## Target
- .NET 8 (LTS), `net8.0` - .NET 8 (LTS), `net8.0` (C# 12)
- Dependencies: `MessagePack` (serialization), `R3` (reactivity) - Dependencies: `MessagePack` (serialization), `R3` (reactivity)
- Output: `OECS.dll` - Output: `OECS.dll`
- Source generator: `OECS.SourceGen.dll` (component registry for serialization)
--- ---
@ -22,8 +23,12 @@ ones. Architecture decisions are captured in `docs/architecture.md`.
``` ```
OECS.sln OECS.sln
├── src/OECS/OECS.csproj ├── OECS/OECS.csproj
└── tests/OECS.Tests/OECS.Tests.csproj ├── OECS.SourceGen/OECS.SourceGen.csproj
├── OECS.Tests/OECS.Tests.csproj
├── Game.Blackjack/Blackjack.csproj
├── Game.Blackjack.Tests/Game.Blackjack.Tests.csproj
└── Game.TicTacToe/...
``` ```
- `OECS.csproj` targets `net8.0`, references `MessagePack` and `R3`. - `OECS.csproj` targets `net8.0`, references `MessagePack` and `R3`.
@ -76,6 +81,8 @@ Holds a `Dictionary<Type, object>` mapping component types to their
- `void Add<T>(Entity, T)` - `void Add<T>(Entity, T)`
- `void Remove<T>(Entity)` - `void Remove<T>(Entity)`
- `ref T Get<T>(Entity)` - `ref T Get<T>(Entity)`
- `T Read<T>(Entity)` — copy without auto-mark
- `bool TryGet<T>(Entity, out T)`
- `bool Has<T>(Entity)` - `bool Has<T>(Entity)`
- `void RemoveAll(Entity)` — called on entity destruction - `void RemoveAll(Entity)` — called on entity destruction
@ -99,6 +106,8 @@ void DestroyEntity(Entity entity);
void AddComponent<T>(Entity entity, T component) where T : struct; void AddComponent<T>(Entity entity, T component) where T : struct;
void RemoveComponent<T>(Entity entity) where T : struct; void RemoveComponent<T>(Entity entity) where T : struct;
ref T GetComponent<T>(Entity entity) where T : struct; ref T GetComponent<T>(Entity entity) where T : struct;
T ReadComponent<T>(Entity entity) where T : struct;
bool TryGetComponent<T>(Entity entity, out T value) where T : struct;
bool HasComponent<T>(Entity entity) where T : struct; bool HasComponent<T>(Entity entity) where T : struct;
bool IsAlive(Entity entity); bool IsAlive(Entity entity);
``` ```
@ -128,8 +137,8 @@ A query is defined by:
``` ```
class QueryDescriptor class QueryDescriptor
{ {
HashSet<Type> With { get; } IReadOnlySet<Type> With { get; }
HashSet<Type> Without { get; } IReadOnlySet<Type> Without { get; }
} }
``` ```
@ -147,49 +156,74 @@ world.Query()
### 2.3 Query Execution ### 2.3 Query Execution
`World` provides iteration over matching entities. The smallest "with" sparse `World` provides two iteration styles:
set is used as the driver; other sets are probed for membership.
**ForEach callbacks** (16 component types):
```csharp ```csharp
void ForEach<T1, T2>(QueryDescriptor query, Action<Entity, ref T1, ref T2> action); void ForEach<T1, T2>(QueryDescriptor query, ForEachAction<T1, T2> action);
``` ```
Overloads for 16 component types. The `Without` filter is checked by probing **Ref struct iterators** via `EntityIterator.Select<T>()` extensions (13 component types):
the corresponding sparse sets. ```csharp
using var iter = world.Select<Position, Velocity>(query);
while (iter.MoveNext()) { ... }
```
Both accept an optional `QueryDescriptor`. When omitted, all entities with
the given component types are iterated (no `Without` filter).
The smallest "with" sparse set is used as the driver; other sets are probed
for membership. The singleton entity (ID 1) is always skipped.
### 2.4 ISystem ### 2.4 ISystem
``` ```
interface ISystem interface ISystem
{ {
QueryDescriptor Query { get; }
void Run(World world); void Run(World world);
} }
``` ```
Systems declare their query and receive the world in `Run`. They call Systems do **not** declare a query on the interface. Instead, they either
`world.ForEach(query, ...)` to iterate. read singletons directly or build queries internally and iterate with
`ForEach` / `Select`. This keeps the interface minimal and gives systems
full flexibility.
Alternative considered: auto-injection of component refs. Rejected because it **Why no auto-injection?** The explicit API makes the iteration cost
hides the iteration cost and makes the API less explicit. The explicit visible and avoids the need for source generators or reflection for
`ForEach` call keeps the system author aware of what they're iterating. system dispatch.
### 2.5 System Registration & Ordering ### 2.5 ITickedSystem
```
interface ITickedSystem : ISystem
{
void Run(World world, Tick tick);
}
```
Systems that need tick metadata (delta time or logical tick marker)
implement this. Both `Run` overloads must be implemented; `SystemGroup`
dispatches to the appropriate one at runtime.
### 2.6 System Registration & Ordering
``` ```
class SystemGroup class SystemGroup
{ {
void Add(ISystem system); // registration order = execution order void Add(ISystem system); // registration order = execution order
void RunTimed(float deltaTime); // calls each system's Run void Remove(ISystem system);
void RunLogical(); // calls each system's Run void RunTimed(float deltaTime);
void RunLogical();
int Count { get; }
} }
``` ```
Systems run in registration order. For now, no explicit dependency graph — Systems run in registration order. `SystemGroup` automatically:
this is the simplest model that works. If needed later, `Before()`/`After()` - Drains commands before the tick, after each system, and after the full tick.
constraints can be added without breaking the API. - Posts changes after each system and after the full tick.
### 2.6 Tick ### 2.7 Tick
``` ```
readonly struct Tick readonly struct Tick
@ -199,16 +233,7 @@ readonly struct Tick
} }
``` ```
Passed to systems that opt into it via a separate interface: ### 2.8 Tests
```
interface ITickedSystem : ISystem
{
void Run(World world, Tick tick);
}
```
### 2.7 Tests
- Query with single component returns matching entities. - Query with single component returns matching entities.
- Query with multiple components returns intersection. - Query with multiple components returns intersection.
@ -216,6 +241,7 @@ interface ITickedSystem : ISystem
- Adding/removing components updates query results. - Adding/removing components updates query results.
- Systems run in registration order. - Systems run in registration order.
- Destroyed entities don't appear in queries. - Destroyed entities don't appear in queries.
- `ITickedSystem` receives correct tick data.
--- ---
@ -226,31 +252,38 @@ interface ITickedSystem : ISystem
### 3.1 ICommand ### 3.1 ICommand
``` ```
[MessagePackObject]
interface ICommand interface ICommand
{ {
void Execute(World world); void Execute(World world);
} }
``` ```
Commands are `[MessagePackObject]` structs implementing `ICommand`. They are Commands are structs implementing `ICommand`. They are typically
not stored in ECS sparse sets — they live in a queue. `[MessagePackObject]` for serialization. Not stored in ECS sparse
sets — they live in a queue.
### 3.2 CommandQueue ### 3.2 CommandQueue
``` ```
class CommandQueue class CommandQueue
{ {
void Enqueue(ICommand command); void Enqueue<T>(T command) where T : struct, ICommand;
void ExecuteAll(World world); // FIFO, clears queue void ExecuteAll(World world); // FIFO, fully drains queue
void Clear();
void ClearErrors();
int Count { get; } int Count { get; }
IReadOnlyList<Exception> Errors { get; }
} }
``` ```
`Enqueue<T>` uses a constrained generic to avoid boxing at the call site.
Commands enqueued during `ExecuteAll` are processed in the same drain cycle.
### 3.3 Integration with World ### 3.3 Integration with World
`World` owns a `CommandQueue`. After each system runs (or after the full tick), `World` owns a `CommandQueue`. After each system runs (or after the full tick),
the queue is drained. This is configurable: the queue is drained automatically by `SystemGroup`. Manual drain is also
available:
```csharp ```csharp
world.ExecuteCommands(); // manual drain world.ExecuteCommands(); // manual drain
@ -279,36 +312,48 @@ available via `CommandQueue.Errors`.
**Goal:** Relationship components with auto-managed source/target and reverse **Goal:** Relationship components with auto-managed source/target and reverse
lookup. lookup.
### 4.1 Relationship\<TSelf, TTarget\> ### 4.1 IRelationship
```
interface IRelationship
{
Entity Source { get; }
Entity Target { get; }
}
```
### 4.2 Relationship\<TSelf, TTarget\>
``` ```
[MessagePackObject] [MessagePackObject]
struct Relationship<TSelf, TTarget> : IRelationship struct Relationship<TSelf, TTarget> : IRelationship
where TSelf : struct where TTarget : struct
{ {
[Key(0)] Entity Source { get; set; } [Key(0)] Entity Source { get; set; }
[Key(1)] Entity Target { get; set; } [Key(1)] Entity Target { get; set; }
// ... payload fields
} }
``` ```
The `IRelationship` marker interface lets `ComponentStore` detect relationships The `IRelationship` marker interface lets `ComponentStore` detect relationships
and maintain the reverse index. and maintain the reverse index. The `TSelf`/`TTarget` phantom types
differentiate relationship kinds at the type level.
### 4.2 Reverse Index Alternatively, implement `IRelationship` directly on your own struct (see the
Blackjack `Holds` and `InDeck` types).
`World` maintains a `Dictionary<Entity, HashSet<Entity>>` per relationship ### 4.3 Reverse Index
type, mapping target → set of source entities.
When a relationship component is added/removed, the index is updated `World` maintains a reverse index per relationship type, mapping target →
automatically. set of source entities. Updated automatically when relationship components
are added/removed.
### 4.3 Reverse Lookup API ### 4.4 Reverse Lookup API
```csharp ```csharp
IReadOnlyCollection<Entity> GetSources<T>(Entity target) where T : struct, IRelationship; IReadOnlyCollection<Entity> GetSources<T>(Entity target) where T : struct, IRelationship;
``` ```
### 4.4 Cascading Behavior ### 4.5 Cascading Behavior
When an entity is destroyed: When an entity is destroyed:
- All relationships where it is the **source** are removed (components dropped). - All relationships where it is the **source** are removed (components dropped).
@ -316,7 +361,7 @@ When an entity is destroyed:
from source entities). from source entities).
- The reverse index is cleaned up. - The reverse index is cleaned up.
### 4.5 Tests ### 4.6 Tests
- Adding a relationship updates the reverse index. - Adding a relationship updates the reverse index.
- Removing a relationship updates the reverse index. - Removing a relationship updates the reverse index.
@ -346,23 +391,17 @@ struct EntityChange
{ {
Entity Entity { get; } Entity Entity { get; }
ChangeKind Kind { get; } ChangeKind Kind { get; }
Type ComponentType { get; } // null for entity-level changes Type? ComponentType { get; } // null for entity-level changes
} }
``` ```
### 5.2 ChangeSet ### 5.2 ChangeBuffer
``` ```
class ChangeSet class ChangeBuffer
{ {
void MarkEntityAdded(Entity entity); void Mark<...>(...);
void MarkEntityRemoved(Entity entity); void Post(); // pushes to R3 subjects, then clears
void MarkComponentAdded(Entity entity, Type componentType);
void MarkComponentRemoved(Entity entity, Type componentType);
void MarkComponentModified(Entity entity, Type componentType);
IReadOnlyList<EntityChange> Changes { get; }
void Clear();
} }
``` ```
@ -381,25 +420,15 @@ Rationale: structural changes are always detectable. Value mutations inside a
value. Requiring an explicit `MarkModified` call is the simplest correct value. Requiring an explicit `MarkModified` call is the simplest correct
approach. approach.
**Debug aid:** In `DEBUG` builds, `ref T Get<T>(Entity)` returns a wrapper that **`ReadComponent<T>`** (returns a copy) and **`ReadSingleton<T>`** never
tracks whether the value was written. If a system iterates `ref T` and never auto-mark. Use these when you only need to inspect state without signaling
calls `MarkModified`, a warning is logged. This catches the most common changes.
mistake.
### 5.4 Posting Model ### 5.4 Posting Model
``` - During a system's `Run`, changes accumulate in a pending buffer.
class ChangeBuffer - After each system's `Run`, `Post()` is called automatically by `SystemGroup`.
{ - After the full tick, `Post()` is called once more.
ChangeSet Pending { get; } // accumulates during system run
void Post(); // pushes to R3 subjects, then clears
}
```
- During a system's `Run`, changes accumulate in `Pending`.
- After each system's `Run`, `Post()` is called automatically.
- After the full tick, `Post()` is called once more (for any changes made
outside systems, e.g., during command execution).
- When not in a system run, changes are **not** posted automatically — the - When not in a system run, changes are **not** posted automatically — the
caller must call `world.PostChanges()`. caller must call `world.PostChanges()`.
@ -408,9 +437,9 @@ class ChangeBuffer
`World` exposes observables: `World` exposes observables:
```csharp ```csharp
IObservable<EntityChange> ObserveEntityChanges(); Observable<EntityChange> ObserveEntityChanges();
IObservable<EntityChange> ObserveComponentChanges<T>() where T : struct; Observable<EntityChange> ObserveComponentChanges<T>() where T : struct;
IObservable<EntityChange> ObserveQuery(QueryDescriptor query); Observable<EntityChange> ObserveQuery(QueryDescriptor query);
``` ```
These are backed by `Subject<EntityChange>` instances. Subscribers receive These are backed by `Subject<EntityChange>` instances. Subscribers receive
@ -445,19 +474,21 @@ world.ObserveComponentChanges<Health>()
### 6.1 Singleton Entity ### 6.1 Singleton Entity
`World` reserves entity ID `1` as the singleton entity. It is never destroyed `World` reserves entity ID `1` as the singleton entity. It is never destroyed
and is excluded from normal queries by default. and is excluded from normal queries (all iterators skip ID 1).
### 6.2 Singleton Accessors ### 6.2 Singleton Accessors
```csharp ```csharp
void SetSingleton<T>(T component) where T : struct; void SetSingleton<T>(T component) where T : struct;
ref T GetSingleton<T>() where T : struct; ref T GetSingleton<T>() where T : struct;
T ReadSingleton<T>() where T : struct;
bool HasSingleton<T>() where T : struct; bool HasSingleton<T>() where T : struct;
void RemoveSingleton<T>() where T : struct; void RemoveSingleton<T>() where T : struct;
``` ```
These are convenience wrappers around `AddComponent`/`GetComponent` on the These are convenience wrappers around `AddComponent`/`GetComponent` on the
singleton entity. singleton entity. `GetSingleton` returns a `ref` (auto-marks during iteration);
`ReadSingleton` returns a copy (never auto-marks).
### 6.3 Query Exclusion ### 6.3 Query Exclusion
@ -473,28 +504,62 @@ to include it, they can query it by its entity ID directly.
--- ---
## Phase 7 — Polish & Documentation (Week 7) ## Phase 7 — Source Generator & Serialization (Week 7)
### 7.1 XML Docs **Goal:** Compile-time component registry for serialization without reflection.
### 7.1 OECS.SourceGen
A Roslyn incremental source generator that scans for all types used as
generic arguments to `World` methods (`AddComponent<T>`, `GetComponent<T>`,
`SetSingleton<T>`, etc.) and generates a `ComponentRegistry` class with
per-type serialize/deserialize callbacks via MessagePack.
### 7.2 WorldSerializer
```csharp
static class WorldSerializer
{
static void Save(World world, Stream stream);
static void Load(World world, Stream stream);
}
```
Uses `ComponentRegistry` to serialize/deserialize all entities and their
components to/from a MessagePack stream. `IRelationship.Source` is fixed
up to the owning entity on load.
### 7.3 Snapshot Types
`WorldSnapshot`, `EntitySnapshot`, and `ComponentEntry` are the public
serialization DTOs.
---
## Phase 8 — Polish & Documentation (Week 8)
### 8.1 XML Docs
All public API surface gets `<summary>` XML documentation comments. All public API surface gets `<summary>` XML documentation comments.
### 7.2 README ### 8.2 README
Quick-start guide with a minimal example: create world, register system, run Quick-start guide with a minimal example: create world, register system, run
tick, observe changes. tick, observe changes.
### 7.3 NuGet Packaging ### 8.3 NuGet Packaging
`OECS.csproj` includes package metadata: `OECS.csproj` includes package metadata:
- `PackageId`: `OECS` - `PackageId`: `OECS`
- `Description`: "Observable ECS for C# — an entity component system focused on - `Description`: "Observable ECS for C# — an entity component system focused on
a clean reactive API surface." a clean reactive API surface."
- `PackageTags`: `ecs;reactive;observable;gamedev` - `PackageTags`: `ecs;reactive;observable;gamedev`
- Bundles `OECS.SourceGen.dll` as an analyzer for consumers.
### 7.4 CI (optional) ### 8.4 Testing Games
GitHub Actions workflow: build, test, pack. Test games (`Game.Blackjack`, `Game.TicTacToe`) serve as integration tests
and design validation. See `docs/testing-games.md` for the testing strategy.
--- ---
@ -507,7 +572,8 @@ Phase 1 (Core)
└─→ Phase 4 (Relationships) └─→ Phase 4 (Relationships)
└─→ Phase 5 (Reactivity) └─→ Phase 5 (Reactivity)
└─→ Phase 6 (Singletons) └─→ Phase 6 (Singletons)
└─→ Phase 7 (Polish) └─→ Phase 7 (Source Gen & Serialization)
└─→ Phase 8 (Polish)
``` ```
Phases 3 and 4 can be done in parallel; Phase 5 depends on both. Phases 3 and 4 can be done in parallel; Phase 5 depends on both.
@ -520,8 +586,8 @@ Phases 3 and 4 can be done in parallel; Phase 5 depends on both.
default. If needed, systems could declare read/write component access for default. If needed, systems could declare read/write component access for
automatic parallel scheduling — but this adds significant complexity. automatic parallel scheduling — but this adds significant complexity.
2. **World serialization?** Since components are MessagePack-serializable, 2. **Multiple worlds?** The design supports it naturally — `World` is a class,
snapshotting the entire world is feasible. This is a Phase 7+ stretch goal.
3. **Multiple worlds?** The design supports it naturally — `World` is a class,
you can instantiate multiple. No cross-world references are supported. you can instantiate multiple. No cross-world references are supported.
3. **Component import from CSV/MasterMemory?** The `design.md` mentions this as
a potential feature. Not yet implemented.

53
docs/testing-games.md Normal file
View File

@ -0,0 +1,53 @@
# Testing games
1. Games are DLLs. Use a standalone test project to test a game.
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
The goal of playtests is to execute a game with AI players and potentially
random seeds. Then the log/snapshots are read by the LLM agent to spot issues.
There is no failure in play tests; all tests pass but the agent should read the
log for reference.
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 one with
highest score. There can be multiple Greedy agents with different scoring.
For each playtest attempt, generate a full game log, analyze and report what
you find from the log.
### Round Runner
Playtests should run multiple rounds, not just one. The recommended pattern:
```
while chips >= minimum_bet AND chips < 2× starting_chips:
place bet → deal → agent decides hit/stand → resolve → new round
```
This exercises the full game lifecycle including deck reshuffling, round
transitions, and chip management. Safety cap at ~200 rounds to prevent
infinite loops.
### Play Log Format
Each play log should contain:
- **Header:** agent name, seed, result summary, win/loss/push counts.
- **Round Summaries:** per-round result, chip delta, hit count.
- **Decisions:** per-decision hand total and choice (Hit/Stand).
- **Reactivity:** raw R3 observable log of component/entity changes.
- **Final State:** snapshot of the world after all rounds.
Logs are saved as `.playlog` files alongside test output for manual review.

View File

@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Blackjack</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OECS.SourceGen\OECS.SourceGen.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>

View File

@ -1,114 +0,0 @@
using OECS;
namespace Blackjack;
public static class Program
{
public static void Main()
{
var world = new World();
// ── Initialize game state ─────────────────────────────────────
// Use a seed based on current time, or a fixed seed for reproducibility.
uint seed = (uint)Environment.TickCount;
// Uncomment the next line for a deterministic game:
// seed = 12345u;
world.SetSingleton(new GameState
{
Phase = GamePhase.Betting,
Result = RoundResult.None,
Chips = 100,
CurrentBet = 0,
RoundNumber = 1,
Seed = seed
});
world.PostChanges();
// ── Wire up systems ───────────────────────────────────────────
var group = new SystemGroup(world);
group.Add(new DeckSetupSystem());
group.Add(new DealSystem());
group.Add(new PlayerBustCheckSystem());
group.Add(new DealerSystem());
group.Add(new RenderSystem());
// ── Game loop ─────────────────────────────────────────────────
group.RunLogical();
while (true)
{
var state = world.ReadSingleton<GameState>();
// Check for game over (out of chips).
if (state.Chips <= 0 && state.Phase == GamePhase.Betting)
{
Console.WriteLine();
Console.WriteLine(" You're out of chips! Game over.");
break;
}
switch (state.Phase)
{
case GamePhase.Betting:
var input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
continue;
if (input.Trim().ToLower() == "quit")
return;
if (int.TryParse(input.Trim(), out var bet))
{
world.Commands.Enqueue(new PlaceBetCommand { Amount = bet });
}
else
{
Console.WriteLine(" Invalid bet. Enter a number.");
continue;
}
break;
case GamePhase.PlayerTurn:
var choice = Console.ReadLine();
if (string.IsNullOrWhiteSpace(choice))
continue;
if (choice.Trim().ToLower() == "quit")
return;
switch (choice.Trim().ToLower())
{
case "h":
case "hit":
world.Commands.Enqueue(new HitCommand());
break;
case "s":
case "stand":
world.Commands.Enqueue(new StandCommand());
break;
default:
Console.WriteLine(" Invalid choice. Enter H or S.");
continue;
}
break;
case GamePhase.RoundOver:
Console.ReadLine();
ref var roundState = ref world.GetSingleton<GameState>();
roundState.RoundNumber++;
world.MarkModified<GameState>(World.SingletonEntity);
world.PostChanges();
world.Commands.Enqueue(new NewRoundCommand());
break;
default:
// Dealing, DealerTurn — systems handle these automatically.
break;
}
group.RunLogical();
}
}
}

View File

@ -1,148 +0,0 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Renders the current game state to the console.
/// </summary>
public class RenderSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
Console.WriteLine();
Console.WriteLine(" Blackjack");
Console.WriteLine(" ═════════");
Console.WriteLine();
Console.WriteLine($" Chips: {state.Chips} | Round: {state.RoundNumber}");
Console.WriteLine();
// Show dealer's hand.
var dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
Console.Write(" Dealer: ");
if (state.Phase == GamePhase.PlayerTurn)
{
// Hide first card (hole card) during player's turn.
Console.Write("?? ");
PrintHand(world, DealerHandTag.Instance, skipFirst: true);
}
else
{
PrintHand(world, DealerHandTag.Instance);
Console.Write($" ({dealerTotal})");
}
Console.WriteLine();
// Show player's hand.
var playerTotal = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
Console.Write(" Player: ");
PrintHand(world, PlayerHandTag.Instance);
Console.Write($" ({playerTotal})");
Console.WriteLine();
Console.WriteLine();
// Status line.
switch (state.Phase)
{
case GamePhase.Betting:
Console.Write($" Enter bet (1{state.Chips}): ");
break;
case GamePhase.PlayerTurn:
Console.Write(" [H]it or [S]tand: ");
break;
case GamePhase.RoundOver:
Console.WriteLine($" {ResultText(state.Result)}");
Console.WriteLine();
Console.Write(" Press Enter for next round...");
break;
default:
break;
}
}
private static void PrintHand(World world, DealerHand hand, bool skipFirst = false)
{
PrintHandImpl(world, hand, skipFirst);
}
private static void PrintHand(World world, PlayerHand hand)
{
PrintHandImpl(world, hand, skipFirst: false);
}
private static void PrintHandImpl<T>(World world, T handTag, bool skipFirst)
where T : struct
{
Entity handEntity = Entity.Null;
using (var iter = world.Select<T>())
{
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
{
handEntity = iter.CurrentEntity;
break;
}
}
}
if (handEntity == Entity.Null)
return;
var cards = world.GetSources<Holds>(handEntity);
var ordered = cards.OrderByDescending(c =>
{
var card = world.ReadComponent<Card>(c);
return (int)card.Rank;
}).ToList();
bool first = true;
foreach (var cardEntity in ordered)
{
if (skipFirst && first)
{
first = false;
continue;
}
first = false;
var card = world.ReadComponent<Card>(cardEntity);
Console.Write(CardToString(card));
Console.Write(" ");
}
}
private static string CardToString(Card card)
{
var suit = card.Suit switch
{
Suit.Hearts => "♥",
Suit.Diamonds => "♦",
Suit.Clubs => "♣",
Suit.Spades => "♠",
_ => "?"
};
var rank = card.Rank switch
{
Rank.Ace => "A",
Rank.Jack => "J",
Rank.Queen => "Q",
Rank.King => "K",
_ => ((int)card.Rank).ToString()
};
return $"{rank}{suit}";
}
private static string ResultText(RoundResult result) => result switch
{
RoundResult.PlayerBust => "You bust! Dealer wins.",
RoundResult.DealerBust => "Dealer busts! You win!",
RoundResult.PlayerWin => "You win!",
RoundResult.DealerWin => "Dealer wins.",
RoundResult.Push => "Push — it's a tie!",
RoundResult.Blackjack => "Blackjack!",
_ => ""
};
}

View File

@ -1,153 +0,0 @@
using OECS;
using R3;
namespace TicTacToe;
public static class Program
{
private static readonly string SavePath = Path.Combine(
AppContext.BaseDirectory, "save.bin");
public static void Main()
{
var world = new World();
var reactivityLog = new List<string>();
SetupReactivityLogging(world, reactivityLog);
// ── Try to load a saved game ──────────────────────────────────
bool loaded = false;
if (File.Exists(SavePath))
{
Console.Write("Saved game found. Load it? (y/n): ");
if (Console.ReadLine()?.Trim().ToLower() == "y")
{
using var stream = File.OpenRead(SavePath);
WorldSerializer.Load(world, stream);
loaded = true;
Console.WriteLine("Game loaded!");
Console.WriteLine();
}
}
// ── Load fresh board from CSV (if not loaded) ─────────────────
if (!loaded)
{
var csvPath = Path.Combine(AppContext.BaseDirectory, "Data", "board.csv");
CsvLoader.LoadCells(world, csvPath);
world.PostChanges();
world.SetSingleton(new GameState
{
CurrentPlayer = Player.X,
Status = GameStatus.Playing,
MoveCount = 0
});
world.PostChanges();
}
// ── Wire up systems ───────────────────────────────────────────
var group = new SystemGroup(world);
group.Add(new WinCheckSystem());
group.Add(new RenderSystem());
// ── Game loop ─────────────────────────────────────────────────
group.RunLogical();
PrintReactivityLog(reactivityLog);
while (true)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Status != GameStatus.Playing)
break;
var input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
continue;
// "save" command.
if (input.Trim().ToLower() == "save")
{
SaveGame(world);
Console.WriteLine(" Game saved.");
continue;
}
var parts = input.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2 ||
!int.TryParse(parts[0], out var row) ||
!int.TryParse(parts[1], out var col))
{
Console.WriteLine(" Invalid input. Try \"1 2\" or \"save\".");
continue;
}
if (row < 0 || row > 2 || col < 0 || col > 2)
{
Console.WriteLine(" Row and col must be 02.");
continue;
}
world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col });
group.RunLogical();
PrintReactivityLog(reactivityLog);
// Auto-save after every move.
SaveGame(world);
}
// ── Final state ───────────────────────────────────────────────
Console.WriteLine();
Console.WriteLine("=== Game over ===");
PrintReactivityLog(reactivityLog);
// Clean up save file on game over.
if (File.Exists(SavePath))
File.Delete(SavePath);
}
private static void SaveGame(World world)
{
using var stream = File.Create(SavePath);
WorldSerializer.Save(world, stream);
}
private static void SetupReactivityLogging(World world, List<string> log)
{
world.ObserveEntityChanges().Subscribe(change =>
{
if (change.ComponentType != null) return;
log.Add($"[react] {change}");
});
world.ObserveComponentChanges<Mark>().Subscribe(change =>
{
log.Add($"[react] {change}");
});
world.ObserveComponentChanges<GameState>().Subscribe(change =>
{
log.Add($"[react] {change}");
});
var markedQuery = world.Query().With<Cell>().With<Mark>().Build();
world.ObserveQuery(markedQuery).Subscribe(change =>
{
log.Add($"[react:query] {change}");
});
}
private static void PrintReactivityLog(List<string> log)
{
if (log.Count == 0) return;
Console.WriteLine("── reactivity log ──");
foreach (var entry in log)
Console.WriteLine(entry);
Console.WriteLine();
log.Clear();
}
}

View File

@ -1,61 +0,0 @@
using OECS;
namespace TicTacToe;
/// <summary>
/// Prints the board to the console.
/// </summary>
public class RenderSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
// Build a 3×3 grid.
var grid = new char[3, 3];
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
grid[r, c] = '.';
// Fill in placed marks using the iterator API.
using var markIter = world.Select<Cell, Mark>();
while (markIter.MoveNext())
{
grid[markIter.Current1.Row, markIter.Current1.Col] =
markIter.Current2.Player == Player.X ? 'X' : 'O';
}
Console.WriteLine();
Console.WriteLine(" Tic-Tac-Toe");
Console.WriteLine(" ═══════════");
Console.WriteLine();
Console.WriteLine(" 0 1 2");
Console.WriteLine(" ┌───┬───┬───┐");
for (int r = 0; r < 3; r++)
{
Console.Write($"{r} │");
for (int c = 0; c < 3; c++)
{
Console.Write($" {grid[r, c]} ");
if (c < 2) Console.Write("│");
}
Console.WriteLine("│");
if (r < 2) Console.WriteLine(" ├───┼───┼───┤");
}
Console.WriteLine(" └───┴───┴───┘");
Console.WriteLine();
// Status line.
var statusText = state.Status switch
{
GameStatus.Playing => $"{(state.CurrentPlayer == Player.X ? 'X' : 'O')}'s turn (move {state.MoveCount + 1})",
GameStatus.XWon => "X wins!",
GameStatus.OWon => "O wins!",
GameStatus.Draw => "It's a draw!",
_ => ""
};
Console.WriteLine($" {statusText}");
Console.WriteLine();
Console.Write(" Enter row col (e.g. \"1 2\"): ");
}
}

View File

@ -1,5 +0,0 @@
0 0
1 1
0 1
1 0
0 2

View File

@ -1,3 +0,0 @@
y
1 0
0 2

View File

@ -1,3 +0,0 @@
0 0
1 1
0 1

View File

@ -1,51 +0,0 @@
using System.Runtime.CompilerServices;
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.
/// </summary>
public static class ComponentRegistry
{
private static ComponentDescriptor[]? _descriptors;
private static Dictionary<string, ComponentDescriptor>? _byTypeName;
/// <summary>
/// All discovered component descriptors. Populated automatically
/// by the source generator at module initialization.
/// </summary>
public static ComponentDescriptor[] Descriptors =>
_descriptors ?? Array.Empty<ComponentDescriptor>();
/// <summary>
/// Lookup by assembly-qualified type name. Populated automatically
/// by the source generator at module initialization.
/// </summary>
public static IReadOnlyDictionary<string, ComponentDescriptor> ByTypeName
{
get
{
if (_byTypeName == null)
{
var dict = new Dictionary<string, ComponentDescriptor>();
foreach (var desc in Descriptors)
dict[desc.TypeName] = desc;
_byTypeName = dict;
}
return _byTypeName;
}
}
/// <summary>
/// Called by generated code to register discovered component types.
/// Must be called before any serialization occurs.
/// </summary>
public static void Initialize(ComponentDescriptor[] descriptors)
{
_descriptors = descriptors;
_byTypeName = null; // Rebuild on next access.
}
}