diff --git a/Game.Blackjack.Tests/Game.Blackjack.Tests.csproj b/Game.Blackjack.Tests/Game.Blackjack.Tests.csproj
new file mode 100644
index 0000000..a898773
--- /dev/null
+++ b/Game.Blackjack.Tests/Game.Blackjack.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+
+ Game.Blackjack.Tests
+ false
+ true
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
diff --git a/Game.Blackjack.Tests/GameFlowTests.cs b/Game.Blackjack.Tests/GameFlowTests.cs
new file mode 100644
index 0000000..3218738
--- /dev/null
+++ b/Game.Blackjack.Tests/GameFlowTests.cs
@@ -0,0 +1,247 @@
+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();
+ 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();
+ 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();
+ 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().Phase.Should().Be(GamePhase.Betting);
+
+ world.Commands.Enqueue(new PlaceBetCommand { Amount = -5 });
+ group.RunLogical();
+ world.ReadSingleton().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(world);
+ deckEntity.Should().NotBe(Entity.Null);
+
+ // Should have player and dealer hand entities.
+ var playerHand = FindEntity(world);
+ playerHand.Should().NotBe(Entity.Null);
+
+ var dealerHand = FindEntity(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(world);
+ var dealerHand = FindEntity(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(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();
+ 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();
+ 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();
+
+ var chipsBefore = world.ReadSingleton().Chips;
+
+ // Hit until bust or stand.
+ while (world.ReadSingleton().Phase == GamePhase.PlayerTurn)
+ {
+ world.Commands.Enqueue(new HitCommand());
+ group.RunLogical();
+ }
+
+ // Game should have resolved.
+ var state = world.ReadSingleton();
+ state.Phase.Should().Be(GamePhase.RoundOver);
+ }
+
+ // ── 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(World world) where T : struct
+ {
+ using var iter = world.Select();
+ 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(handEntity).Count;
+ }
+
+ private static List<(Suit, Rank)> GetAllCards(World world)
+ {
+ var cards = new List<(Suit, Rank)>();
+ var query = world.Query().With().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;
+ }
+}
diff --git a/Blackjack/Blackjack.csproj b/Game.Blackjack/Blackjack.csproj
similarity index 86%
rename from Blackjack/Blackjack.csproj
rename to Game.Blackjack/Blackjack.csproj
index 3e7319f..c7392c2 100644
--- a/Blackjack/Blackjack.csproj
+++ b/Game.Blackjack/Blackjack.csproj
@@ -2,7 +2,7 @@
Library
- Blackjack
+ Game.Blackjack
diff --git a/Blackjack/Commands/HitCommand.cs b/Game.Blackjack/Commands/HitCommand.cs
similarity index 98%
rename from Blackjack/Commands/HitCommand.cs
rename to Game.Blackjack/Commands/HitCommand.cs
index 3228876..4f1677f 100644
--- a/Blackjack/Commands/HitCommand.cs
+++ b/Game.Blackjack/Commands/HitCommand.cs
@@ -1,7 +1,7 @@
using MessagePack;
using OECS;
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// Player hits: draws one card from the deck to the player's hand.
diff --git a/Blackjack/Commands/NewRoundCommand.cs b/Game.Blackjack/Commands/NewRoundCommand.cs
similarity index 98%
rename from Blackjack/Commands/NewRoundCommand.cs
rename to Game.Blackjack/Commands/NewRoundCommand.cs
index afe61b9..3ef5f91 100644
--- a/Blackjack/Commands/NewRoundCommand.cs
+++ b/Game.Blackjack/Commands/NewRoundCommand.cs
@@ -1,7 +1,7 @@
using MessagePack;
using OECS;
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// Starts a new round after the previous one ended.
diff --git a/Blackjack/Commands/PlaceBetCommand.cs b/Game.Blackjack/Commands/PlaceBetCommand.cs
similarity index 95%
rename from Blackjack/Commands/PlaceBetCommand.cs
rename to Game.Blackjack/Commands/PlaceBetCommand.cs
index 527afad..5cbfe02 100644
--- a/Blackjack/Commands/PlaceBetCommand.cs
+++ b/Game.Blackjack/Commands/PlaceBetCommand.cs
@@ -1,7 +1,7 @@
using MessagePack;
using OECS;
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// Places a bet and advances the game to the dealing phase.
diff --git a/Blackjack/Commands/StandCommand.cs b/Game.Blackjack/Commands/StandCommand.cs
similarity index 94%
rename from Blackjack/Commands/StandCommand.cs
rename to Game.Blackjack/Commands/StandCommand.cs
index 97d1ed8..0d8f146 100644
--- a/Blackjack/Commands/StandCommand.cs
+++ b/Game.Blackjack/Commands/StandCommand.cs
@@ -1,7 +1,7 @@
using MessagePack;
using OECS;
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// Player stands: advance to the dealer's turn.
diff --git a/Blackjack/Components/Card.cs b/Game.Blackjack/Components/Card.cs
similarity index 89%
rename from Blackjack/Components/Card.cs
rename to Game.Blackjack/Components/Card.cs
index 9c87084..01aaa6d 100644
--- a/Blackjack/Components/Card.cs
+++ b/Game.Blackjack/Components/Card.cs
@@ -1,6 +1,6 @@
using MessagePack;
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// A playing card with a suit and rank.
diff --git a/Blackjack/Components/DealerHand.cs b/Game.Blackjack/Components/DealerHand.cs
similarity index 69%
rename from Blackjack/Components/DealerHand.cs
rename to Game.Blackjack/Components/DealerHand.cs
index 5dd8fd0..9129087 100644
--- a/Blackjack/Components/DealerHand.cs
+++ b/Game.Blackjack/Components/DealerHand.cs
@@ -1,6 +1,6 @@
using MessagePack;
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// Tag component marking the dealer's hand entity.
diff --git a/Blackjack/Components/Deck.cs b/Game.Blackjack/Components/Deck.cs
similarity index 70%
rename from Blackjack/Components/Deck.cs
rename to Game.Blackjack/Components/Deck.cs
index 9754ee7..a6b178a 100644
--- a/Blackjack/Components/Deck.cs
+++ b/Game.Blackjack/Components/Deck.cs
@@ -1,6 +1,6 @@
using MessagePack;
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// Tag component marking the deck entity.
diff --git a/Blackjack/Components/Holds.cs b/Game.Blackjack/Components/Holds.cs
similarity index 92%
rename from Blackjack/Components/Holds.cs
rename to Game.Blackjack/Components/Holds.cs
index d704e03..46bdf7b 100644
--- a/Blackjack/Components/Holds.cs
+++ b/Game.Blackjack/Components/Holds.cs
@@ -1,7 +1,7 @@
using MessagePack;
using OECS;
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// Relationship from a hand entity to a card entity,
diff --git a/Blackjack/Components/InDeck.cs b/Game.Blackjack/Components/InDeck.cs
similarity index 92%
rename from Blackjack/Components/InDeck.cs
rename to Game.Blackjack/Components/InDeck.cs
index 13b54d0..b7ae47c 100644
--- a/Blackjack/Components/InDeck.cs
+++ b/Game.Blackjack/Components/InDeck.cs
@@ -1,7 +1,7 @@
using MessagePack;
using OECS;
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// Relationship from a card entity to the deck entity,
diff --git a/Blackjack/Components/PlayerHand.cs b/Game.Blackjack/Components/PlayerHand.cs
similarity index 69%
rename from Blackjack/Components/PlayerHand.cs
rename to Game.Blackjack/Components/PlayerHand.cs
index cd35db0..31dc027 100644
--- a/Blackjack/Components/PlayerHand.cs
+++ b/Game.Blackjack/Components/PlayerHand.cs
@@ -1,6 +1,6 @@
using MessagePack;
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// Tag component marking the player's hand entity.
diff --git a/Blackjack/Components/Rank.cs b/Game.Blackjack/Components/Rank.cs
similarity index 88%
rename from Blackjack/Components/Rank.cs
rename to Game.Blackjack/Components/Rank.cs
index 3458aa0..b3bc4ad 100644
--- a/Blackjack/Components/Rank.cs
+++ b/Game.Blackjack/Components/Rank.cs
@@ -1,4 +1,4 @@
-namespace Blackjack;
+namespace Game.Blackjack;
public enum Rank : byte
{
diff --git a/Blackjack/Components/Suit.cs b/Game.Blackjack/Components/Suit.cs
similarity index 77%
rename from Blackjack/Components/Suit.cs
rename to Game.Blackjack/Components/Suit.cs
index ab16b9b..978b1cf 100644
--- a/Blackjack/Components/Suit.cs
+++ b/Game.Blackjack/Components/Suit.cs
@@ -1,4 +1,4 @@
-namespace Blackjack;
+namespace Game.Blackjack;
public enum Suit : byte
{
diff --git a/Blackjack/Mulberry32.cs b/Game.Blackjack/Mulberry32.cs
similarity index 96%
rename from Blackjack/Mulberry32.cs
rename to Game.Blackjack/Mulberry32.cs
index 406bb48..6e2f772 100644
--- a/Blackjack/Mulberry32.cs
+++ b/Game.Blackjack/Mulberry32.cs
@@ -1,4 +1,4 @@
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator.
diff --git a/Blackjack/Singletons/GamePhase.cs b/Game.Blackjack/Singletons/GamePhase.cs
similarity index 82%
rename from Blackjack/Singletons/GamePhase.cs
rename to Game.Blackjack/Singletons/GamePhase.cs
index 4267941..4bba2b4 100644
--- a/Blackjack/Singletons/GamePhase.cs
+++ b/Game.Blackjack/Singletons/GamePhase.cs
@@ -1,4 +1,4 @@
-namespace Blackjack;
+namespace Game.Blackjack;
public enum GamePhase : byte
{
diff --git a/Blackjack/Singletons/GameState.cs b/Game.Blackjack/Singletons/GameState.cs
similarity index 93%
rename from Blackjack/Singletons/GameState.cs
rename to Game.Blackjack/Singletons/GameState.cs
index 19ebe5f..f14f6e2 100644
--- a/Blackjack/Singletons/GameState.cs
+++ b/Game.Blackjack/Singletons/GameState.cs
@@ -1,6 +1,6 @@
using MessagePack;
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// Global game state stored on the singleton entity.
diff --git a/Blackjack/Singletons/RoundResult.cs b/Game.Blackjack/Singletons/RoundResult.cs
similarity index 85%
rename from Blackjack/Singletons/RoundResult.cs
rename to Game.Blackjack/Singletons/RoundResult.cs
index ef350f4..66d5e5d 100644
--- a/Blackjack/Singletons/RoundResult.cs
+++ b/Game.Blackjack/Singletons/RoundResult.cs
@@ -1,4 +1,4 @@
-namespace Blackjack;
+namespace Game.Blackjack;
public enum RoundResult : byte
{
diff --git a/Blackjack/Systems/DealSystem.cs b/Game.Blackjack/Systems/DealSystem.cs
similarity index 94%
rename from Blackjack/Systems/DealSystem.cs
rename to Game.Blackjack/Systems/DealSystem.cs
index 81c0715..6acce10 100644
--- a/Blackjack/Systems/DealSystem.cs
+++ b/Game.Blackjack/Systems/DealSystem.cs
@@ -1,6 +1,6 @@
using OECS;
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// Deals initial two cards to player and dealer when entering the dealing phase.
diff --git a/Blackjack/Systems/DealerSystem.cs b/Game.Blackjack/Systems/DealerSystem.cs
similarity index 98%
rename from Blackjack/Systems/DealerSystem.cs
rename to Game.Blackjack/Systems/DealerSystem.cs
index d3caec4..c8f65a2 100644
--- a/Blackjack/Systems/DealerSystem.cs
+++ b/Game.Blackjack/Systems/DealerSystem.cs
@@ -1,6 +1,6 @@
using OECS;
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// Dealer draws cards until reaching 17 or higher,
diff --git a/Blackjack/Systems/DeckSetupSystem.cs b/Game.Blackjack/Systems/DeckSetupSystem.cs
similarity index 99%
rename from Blackjack/Systems/DeckSetupSystem.cs
rename to Game.Blackjack/Systems/DeckSetupSystem.cs
index 87c54d7..667a594 100644
--- a/Blackjack/Systems/DeckSetupSystem.cs
+++ b/Game.Blackjack/Systems/DeckSetupSystem.cs
@@ -1,6 +1,6 @@
using OECS;
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// Creates the deck (52 cards + deck/hand entities) and shuffles
diff --git a/Blackjack/Systems/HandUtil.cs b/Game.Blackjack/Systems/HandUtil.cs
similarity index 98%
rename from Blackjack/Systems/HandUtil.cs
rename to Game.Blackjack/Systems/HandUtil.cs
index abf5da3..32273ba 100644
--- a/Blackjack/Systems/HandUtil.cs
+++ b/Game.Blackjack/Systems/HandUtil.cs
@@ -1,6 +1,6 @@
using OECS;
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// Helper to calculate the blackjack value of a hand.
diff --git a/Blackjack/Systems/PlayerBustCheckSystem.cs b/Game.Blackjack/Systems/PlayerBustCheckSystem.cs
similarity index 96%
rename from Blackjack/Systems/PlayerBustCheckSystem.cs
rename to Game.Blackjack/Systems/PlayerBustCheckSystem.cs
index a05ca13..4cc6051 100644
--- a/Blackjack/Systems/PlayerBustCheckSystem.cs
+++ b/Game.Blackjack/Systems/PlayerBustCheckSystem.cs
@@ -1,6 +1,6 @@
using OECS;
-namespace Blackjack;
+namespace Game.Blackjack;
///
/// Evaluates the player's hand total after each hit.
diff --git a/Game.TicTacToe.Tests/Game.TicTacToe.Tests.csproj b/Game.TicTacToe.Tests/Game.TicTacToe.Tests.csproj
new file mode 100644
index 0000000..61b1908
--- /dev/null
+++ b/Game.TicTacToe.Tests/Game.TicTacToe.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+
+ Game.TicTacToe.Tests
+ false
+ true
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
diff --git a/Game.TicTacToe.Tests/GameFlowTests.cs b/Game.TicTacToe.Tests/GameFlowTests.cs
new file mode 100644
index 0000000..95931c5
--- /dev/null
+++ b/Game.TicTacToe.Tests/GameFlowTests.cs
@@ -0,0 +1,213 @@
+using FluentAssertions;
+using Game.TicTacToe;
+using OECS;
+using Xunit;
+
+namespace Game.TicTacToe.Tests;
+
+public class GameFlowTests
+{
+ [Fact]
+ public void NewGame_HasEmptyBoard()
+ {
+ var (world, _) = SetupGame();
+
+ var query = world.Query().With().Without().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().With().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().CurrentPlayer.Should().Be(Player.O);
+
+ world.Commands.Enqueue(new PlaceMarkCommand { Row = 1, Col = 0 }); // O
+ group.RunLogical();
+ world.ReadSingleton().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().With().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().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().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().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().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().Status.Should().Be(GameStatus.XWon);
+
+ // Try to place another mark — should be ignored.
+ var moveCountBefore = world.ReadSingleton().MoveCount;
+ world.Commands.Enqueue(new PlaceMarkCommand { Row = 2, Col = 2 });
+ group.RunLogical();
+
+ world.ReadSingleton().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();
+ state.CurrentPlayer.Should().Be(Player.X);
+ state.MoveCount.Should().Be(2);
+
+ var query = world2.Query().With| ().With().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();
+ }
+ }
+}
diff --git a/TicTacToe/Commands/PlaceMarkCommand.cs b/Game.TicTacToe/Commands/PlaceMarkCommand.cs
similarity index 98%
rename from TicTacToe/Commands/PlaceMarkCommand.cs
rename to Game.TicTacToe/Commands/PlaceMarkCommand.cs
index f2f1d4b..dab1200 100644
--- a/TicTacToe/Commands/PlaceMarkCommand.cs
+++ b/Game.TicTacToe/Commands/PlaceMarkCommand.cs
@@ -1,7 +1,7 @@
using MessagePack;
using OECS;
-namespace TicTacToe;
+namespace Game.TicTacToe;
///
/// Places the current player's mark on the cell at (Row, Col).
diff --git a/TicTacToe/Components/Cell.cs b/Game.TicTacToe/Components/Cell.cs
similarity index 88%
rename from TicTacToe/Components/Cell.cs
rename to Game.TicTacToe/Components/Cell.cs
index 18e30b6..425bec9 100644
--- a/TicTacToe/Components/Cell.cs
+++ b/Game.TicTacToe/Components/Cell.cs
@@ -1,6 +1,6 @@
using MessagePack;
-namespace TicTacToe;
+namespace Game.TicTacToe;
///
/// Identifies a board position. One entity per cell.
diff --git a/TicTacToe/Components/Mark.cs b/Game.TicTacToe/Components/Mark.cs
similarity index 89%
rename from TicTacToe/Components/Mark.cs
rename to Game.TicTacToe/Components/Mark.cs
index 567cd6c..b1efbcd 100644
--- a/TicTacToe/Components/Mark.cs
+++ b/Game.TicTacToe/Components/Mark.cs
@@ -1,6 +1,6 @@
using MessagePack;
-namespace TicTacToe;
+namespace Game.TicTacToe;
///
/// A mark placed on a cell by a player.
diff --git a/TicTacToe/Components/Player.cs b/Game.TicTacToe/Components/Player.cs
similarity index 71%
rename from TicTacToe/Components/Player.cs
rename to Game.TicTacToe/Components/Player.cs
index 103885d..b9e8c85 100644
--- a/TicTacToe/Components/Player.cs
+++ b/Game.TicTacToe/Components/Player.cs
@@ -1,4 +1,4 @@
-namespace TicTacToe;
+namespace Game.TicTacToe;
public enum Player : byte
{
diff --git a/TicTacToe/CsvLoader.cs b/Game.TicTacToe/CsvLoader.cs
similarity index 98%
rename from TicTacToe/CsvLoader.cs
rename to Game.TicTacToe/CsvLoader.cs
index 43bd7c0..a742869 100644
--- a/TicTacToe/CsvLoader.cs
+++ b/Game.TicTacToe/CsvLoader.cs
@@ -1,6 +1,6 @@
using OECS;
-namespace TicTacToe;
+namespace Game.TicTacToe;
///
/// Minimal CSV loader that creates entities from a CSV file.
diff --git a/TicTacToe/Data/board.csv b/Game.TicTacToe/Data/board.csv
similarity index 100%
rename from TicTacToe/Data/board.csv
rename to Game.TicTacToe/Data/board.csv
diff --git a/TicTacToe/Singletons/GameState.cs b/Game.TicTacToe/Singletons/GameState.cs
similarity index 91%
rename from TicTacToe/Singletons/GameState.cs
rename to Game.TicTacToe/Singletons/GameState.cs
index 0888da1..87ba4fc 100644
--- a/TicTacToe/Singletons/GameState.cs
+++ b/Game.TicTacToe/Singletons/GameState.cs
@@ -1,6 +1,6 @@
using MessagePack;
-namespace TicTacToe;
+namespace Game.TicTacToe;
///
/// Global game state stored on the singleton entity.
diff --git a/TicTacToe/Singletons/GameStatus.cs b/Game.TicTacToe/Singletons/GameStatus.cs
similarity index 78%
rename from TicTacToe/Singletons/GameStatus.cs
rename to Game.TicTacToe/Singletons/GameStatus.cs
index c1d3946..65005b3 100644
--- a/TicTacToe/Singletons/GameStatus.cs
+++ b/Game.TicTacToe/Singletons/GameStatus.cs
@@ -1,4 +1,4 @@
-namespace TicTacToe;
+namespace Game.TicTacToe;
public enum GameStatus : byte
{
diff --git a/TicTacToe/Systems/WinCheckSystem.cs b/Game.TicTacToe/Systems/WinCheckSystem.cs
similarity index 98%
rename from TicTacToe/Systems/WinCheckSystem.cs
rename to Game.TicTacToe/Systems/WinCheckSystem.cs
index e43bb0e..66a744f 100644
--- a/TicTacToe/Systems/WinCheckSystem.cs
+++ b/Game.TicTacToe/Systems/WinCheckSystem.cs
@@ -1,6 +1,6 @@
using OECS;
-namespace TicTacToe;
+namespace Game.TicTacToe;
///
/// After each move, checks whether the game has been won or drawn.
diff --git a/TicTacToe/TicTacToe.csproj b/Game.TicTacToe/TicTacToe.csproj
similarity index 89%
rename from TicTacToe/TicTacToe.csproj
rename to Game.TicTacToe/TicTacToe.csproj
index cc9eefa..9f191e3 100644
--- a/TicTacToe/TicTacToe.csproj
+++ b/Game.TicTacToe/TicTacToe.csproj
@@ -2,7 +2,7 @@
Library
- TicTacToe
+ Game.TicTacToe
diff --git a/OECS.sln b/OECS.sln
index 4f78a39..7542fa5 100644
--- a/OECS.sln
+++ b/OECS.sln
@@ -8,9 +8,13 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.SourceGen", "OECS.Sour
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.Tests", "OECS.Tests\OECS.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blackjack", "Blackjack\Blackjack.csproj", "{85631EDE-F984-4DA6-8EE9-0715AF1204E6}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Blackjack", "Game.Blackjack\Blackjack.csproj", "{85631EDE-F984-4DA6-8EE9-0715AF1204E6}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TicTacToe", "TicTacToe\TicTacToe.csproj", "{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.TicTacToe", "Game.TicTacToe\TicTacToe.csproj", "{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}"
+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}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -83,7 +87,32 @@ Global
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x86.ActiveCfg = Release|Any CPU
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
+ {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.Build.0 = Debug|Any CPU
+ {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.Build.0 = Debug|Any CPU
+ {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.ActiveCfg = Release|Any CPU
+ {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.Build.0 = Release|Any CPU
+ {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.ActiveCfg = Release|Any CPU
+ {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.Build.0 = Release|Any CPU
+ {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.Build.0 = Debug|Any CPU
+ {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.Build.0 = Debug|Any CPU
+ {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.ActiveCfg = Release|Any CPU
+ {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.Build.0 = Release|Any CPU
+ {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.ActiveCfg = Release|Any CPU
+ {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
diff --git a/docs/testing-games.md b/docs/testing-games.md
new file mode 100644
index 0000000..8272555
--- /dev/null
+++ b/docs/testing-games.md
@@ -0,0 +1,7 @@
+# Testing games
+
+1. Games are dlls. Use a standalone test project to test a game.
+2. Create baseline game snapshots in textual format.
+3. Create baseline logs via the r3 observable api.
+4. Read the snapshot/logs manually to identify issues.
+5. Make sure serialization roundtrip works.
| | | |