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(); // 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); } [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(); 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(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; } }