From b4661e714ec8531d0d82eeacf16c5cdddcaf74c8 Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 20 Jul 2026 16:41:10 +0800 Subject: [PATCH] test: add unit tests for Blackjack and TicTacToe - Add xUnit test projects for both games - Implement game flow tests for Blackjack - Implement game flow tests for TicTacToe - Rename namespaces and projects to follow `Game.*` convention - Add documentation for testing games --- .../Game.Blackjack.Tests.csproj | 24 ++ Game.Blackjack.Tests/GameFlowTests.cs | 247 ++++++++++++++++++ .../Blackjack.csproj | 2 +- .../Commands/HitCommand.cs | 2 +- .../Commands/NewRoundCommand.cs | 2 +- .../Commands/PlaceBetCommand.cs | 2 +- .../Commands/StandCommand.cs | 2 +- .../Components/Card.cs | 2 +- .../Components/DealerHand.cs | 2 +- .../Components/Deck.cs | 2 +- .../Components/Holds.cs | 2 +- .../Components/InDeck.cs | 2 +- .../Components/PlayerHand.cs | 2 +- .../Components/Rank.cs | 2 +- .../Components/Suit.cs | 2 +- {Blackjack => Game.Blackjack}/Mulberry32.cs | 2 +- .../Singletons/GamePhase.cs | 2 +- .../Singletons/GameState.cs | 2 +- .../Singletons/RoundResult.cs | 2 +- .../Systems/DealSystem.cs | 2 +- .../Systems/DealerSystem.cs | 2 +- .../Systems/DeckSetupSystem.cs | 2 +- .../Systems/HandUtil.cs | 2 +- .../Systems/PlayerBustCheckSystem.cs | 2 +- .../Game.TicTacToe.Tests.csproj | 24 ++ Game.TicTacToe.Tests/GameFlowTests.cs | 213 +++++++++++++++ .../Commands/PlaceMarkCommand.cs | 2 +- .../Components/Cell.cs | 2 +- .../Components/Mark.cs | 2 +- .../Components/Player.cs | 2 +- {TicTacToe => Game.TicTacToe}/CsvLoader.cs | 2 +- {TicTacToe => Game.TicTacToe}/Data/board.csv | 0 .../Singletons/GameState.cs | 2 +- .../Singletons/GameStatus.cs | 2 +- .../Systems/WinCheckSystem.cs | 2 +- .../TicTacToe.csproj | 2 +- OECS.sln | 35 ++- docs/testing-games.md | 7 + 38 files changed, 578 insertions(+), 34 deletions(-) create mode 100644 Game.Blackjack.Tests/Game.Blackjack.Tests.csproj create mode 100644 Game.Blackjack.Tests/GameFlowTests.cs rename {Blackjack => Game.Blackjack}/Blackjack.csproj (86%) rename {Blackjack => Game.Blackjack}/Commands/HitCommand.cs (98%) rename {Blackjack => Game.Blackjack}/Commands/NewRoundCommand.cs (98%) rename {Blackjack => Game.Blackjack}/Commands/PlaceBetCommand.cs (95%) rename {Blackjack => Game.Blackjack}/Commands/StandCommand.cs (94%) rename {Blackjack => Game.Blackjack}/Components/Card.cs (89%) rename {Blackjack => Game.Blackjack}/Components/DealerHand.cs (69%) rename {Blackjack => Game.Blackjack}/Components/Deck.cs (70%) rename {Blackjack => Game.Blackjack}/Components/Holds.cs (92%) rename {Blackjack => Game.Blackjack}/Components/InDeck.cs (92%) rename {Blackjack => Game.Blackjack}/Components/PlayerHand.cs (69%) rename {Blackjack => Game.Blackjack}/Components/Rank.cs (88%) rename {Blackjack => Game.Blackjack}/Components/Suit.cs (77%) rename {Blackjack => Game.Blackjack}/Mulberry32.cs (96%) rename {Blackjack => Game.Blackjack}/Singletons/GamePhase.cs (82%) rename {Blackjack => Game.Blackjack}/Singletons/GameState.cs (93%) rename {Blackjack => Game.Blackjack}/Singletons/RoundResult.cs (85%) rename {Blackjack => Game.Blackjack}/Systems/DealSystem.cs (94%) rename {Blackjack => Game.Blackjack}/Systems/DealerSystem.cs (98%) rename {Blackjack => Game.Blackjack}/Systems/DeckSetupSystem.cs (99%) rename {Blackjack => Game.Blackjack}/Systems/HandUtil.cs (98%) rename {Blackjack => Game.Blackjack}/Systems/PlayerBustCheckSystem.cs (96%) create mode 100644 Game.TicTacToe.Tests/Game.TicTacToe.Tests.csproj create mode 100644 Game.TicTacToe.Tests/GameFlowTests.cs rename {TicTacToe => Game.TicTacToe}/Commands/PlaceMarkCommand.cs (98%) rename {TicTacToe => Game.TicTacToe}/Components/Cell.cs (88%) rename {TicTacToe => Game.TicTacToe}/Components/Mark.cs (89%) rename {TicTacToe => Game.TicTacToe}/Components/Player.cs (71%) rename {TicTacToe => Game.TicTacToe}/CsvLoader.cs (98%) rename {TicTacToe => Game.TicTacToe}/Data/board.csv (100%) rename {TicTacToe => Game.TicTacToe}/Singletons/GameState.cs (91%) rename {TicTacToe => Game.TicTacToe}/Singletons/GameStatus.cs (78%) rename {TicTacToe => Game.TicTacToe}/Systems/WinCheckSystem.cs (98%) rename {TicTacToe => Game.TicTacToe}/TicTacToe.csproj (89%) create mode 100644 docs/testing-games.md 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.