Compare commits

..

No commits in common. "b066ac2eba5e655df6050cd69b60a589cbc11fb1" and "5594515a53fcc0142fc7b39f7d9c286ab3c0e357" have entirely different histories.

92 changed files with 834 additions and 2333 deletions

View File

@ -1,9 +0,0 @@
<?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

@ -1,24 +0,0 @@
<?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

@ -1,266 +0,0 @@
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

@ -1,381 +0,0 @@
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

@ -1,259 +0,0 @@
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

@ -1,23 +0,0 @@
<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,24 +0,0 @@
<?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

@ -1,213 +0,0 @@
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

@ -1,284 +0,0 @@
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

@ -1,244 +0,0 @@
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,20 +1,19 @@
Microsoft Visual Studio Solution File, Format Version 12.00

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

View File

@ -1,136 +0,0 @@
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

@ -139,17 +139,13 @@ public delegate void ForEachAction<T1, T2, T3, T4, T5, T6>(Entity entity, ref T1
Extension methods on `World` for `foreach`-style iteration over queries.
Returns `ref struct` iterators that support zero-allocation iteration with
`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.
`ref` access to components. Supports 13 component types.
```csharp
namespace OECS;
public static class EntityIterator
{
// With explicit QueryDescriptor (supports Without<T> filters).
public static Select1<T1> Select<T1>(
this World world, QueryDescriptor query) where T1 : struct;
@ -160,38 +156,16 @@ public static class EntityIterator
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
this World world, QueryDescriptor query)
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;
}
```
The singletons `CurrentEntity`, `Current1`, `Current2`, `Current3` are
exposed on the ref struct iterators directly. Usage:
Usage:
```csharp
// Simple scan: all entities with PlayerHand.
using var iter = world.Select<PlayerHand>();
while (iter.MoveNext())
using var iter = world.Select<Position, Velocity>(query);
foreach (ref var item in iter)
{
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;
item.Current1.X += item.Current2.X * dt;
}
```
@ -228,32 +202,20 @@ public class QueryDescriptor
## 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
namespace OECS;
public interface ISystem
{
QueryDescriptor Query { get; }
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
An optional extension for systems that need tick metadata (delta time or
logical tick marker).
```csharp
namespace OECS;
@ -263,10 +225,6 @@ 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
@ -308,9 +266,6 @@ public class SystemGroup
}
```
Systems execute in registration order. `SystemGroup` automatically drains
commands and posts changes after each system and after the full tick.
---
## ICommand
@ -324,9 +279,6 @@ public interface ICommand
}
```
Implementations should be `[MessagePackObject]` structs so they can be
serialized and replayed.
---
## CommandQueue
@ -345,9 +297,6 @@ 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
@ -375,7 +324,6 @@ namespace OECS;
[MessagePackObject]
public struct Relationship<TSelf, TTarget> : IRelationship
where TSelf : struct where TTarget : struct
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
@ -411,7 +359,7 @@ public enum ChangeKind
## WorldSerializer
Saves and loads a `World` to/from a MessagePack stream. Components are
serialized by their runtime type using the source-generated `ComponentRegistry`.
serialized by their runtime type.
```csharp
namespace OECS;
@ -445,7 +393,6 @@ public class EntitySnapshot
[Key(0)] public uint Id { get; set; }
[Key(1)] public uint Version { get; set; }
[Key(2)] public ComponentEntry[] Components { get; set; }
[IgnoreMember]
public Entity Entity { get; }
}
@ -463,30 +410,37 @@ public class ComponentEntry
```csharp
using OECS;
using R3;
// Define components
[MessagePackObject]
public struct Position
public struct Position : IMessagePackSerializationCallbackReceiver
{
[Key(0)] public float X;
[Key(1)] public float Y;
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
}
[MessagePackObject]
public struct Velocity
public struct Velocity : IMessagePackSerializationCallbackReceiver
{
[Key(0)] public float X;
[Key(1)] public float Y;
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
}
// Define a system
public class MovementSystem : ITickedSystem
{
private readonly QueryDescriptor _query;
public QueryDescriptor Query { get; }
public MovementSystem(World world)
{
_query = world.Query()
Query = world.Query()
.With<Position>()
.With<Velocity>()
.Build();
@ -498,7 +452,7 @@ public class MovementSystem : ITickedSystem
{
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.Y += vel.Y * dt;
@ -512,15 +466,16 @@ var world = new World();
var group = new SystemGroup(world);
group.Add(new MovementSystem(world));
// Observe changes
world.ObserveComponentChanges<Position>()
.Subscribe(change => Console.WriteLine($"{change.Entity} moved"))
.AddTo(disposables);
// Create entities
var player = world.CreateEntity();
world.AddComponent(player, new Position { X = 0, 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
group.RunTimed(0.016f); // ~60 FPS
```
@ -539,6 +494,4 @@ These types are implementation details and may change without notice:
| `ChangeSet` | Deduplicated change accumulator within a single batch. |
| `RelationshipIndex` | Reverse lookup from target entity to source entities. |
| `EntityAllocator` | Free-list + bump allocator for entity IDs. |
| `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. |
| `QueryExecutor` | Query iteration logic with smallest-set driver optimization. |

View File

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

View File

@ -1,53 +0,0 @@
# 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,114 @@
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,4 +1,4 @@
namespace Game.Blackjack;
namespace Blackjack;
public enum GamePhase : byte
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,51 @@
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.
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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