2026-07-20 16:41:10 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 17:05:17 +08:00
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 16:41:10 +08:00
|
|
|
// ── 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
|
|
|
|
|
{
|
2026-07-21 13:57:11 +08:00
|
|
|
return world.FindEntity<T>();
|
2026-07-20 16:41:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static int CountCardsInHand(World world, Entity handEntity)
|
|
|
|
|
{
|
2026-07-20 22:59:27 +08:00
|
|
|
// Holds relationship: the card entity (which Holds points to the hand entity).
|
2026-07-20 16:41:10 +08:00
|
|
|
// 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)>();
|
2026-07-21 13:57:11 +08:00
|
|
|
using (var iter = world.Select<Card>())
|
2026-07-20 16:41:10 +08:00
|
|
|
{
|
2026-07-21 13:57:11 +08:00
|
|
|
while (iter.MoveNext())
|
|
|
|
|
{
|
|
|
|
|
var card = iter.Val1;
|
|
|
|
|
cards.Add((card.Suit, card.Rank));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-20 16:41:10 +08:00
|
|
|
cards.Sort((a, b) =>
|
|
|
|
|
{
|
|
|
|
|
int cmp = a.Item1.CompareTo(b.Item1);
|
|
|
|
|
return cmp != 0 ? cmp : a.Item2.CompareTo(b.Item2);
|
|
|
|
|
});
|
|
|
|
|
return cards;
|
|
|
|
|
}
|
|
|
|
|
}
|