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
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<RootNamespace>Game.Blackjack</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OECS\OECS.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj"
|
||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,58 @@
|
||||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Player hits: draws one card from the deck to the player's hand.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct HitCommand : ICommand
|
||||
{
|
||||
public void Execute(World world)
|
||||
{
|
||||
ref var state = ref world.GetSingleton<GameState>();
|
||||
|
||||
if (state.Phase != GamePhase.PlayerTurn)
|
||||
return;
|
||||
|
||||
DrawCard<PlayerHand>(world);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the top card from the deck and adds it to the given hand.
|
||||
/// </summary>
|
||||
internal static void DrawCard<THand>(World world)
|
||||
where THand : struct
|
||||
{
|
||||
var singletonEntity = World.SingletonEntity;
|
||||
var deckEntity = FindEntity<Deck>(world, singletonEntity);
|
||||
var handEntity = FindEntity<THand>(world, singletonEntity);
|
||||
|
||||
if (deckEntity == Entity.Null || handEntity == Entity.Null)
|
||||
return;
|
||||
|
||||
// Find a card still in the deck.
|
||||
var cardsInDeck = world.GetSources<InDeck>(deckEntity);
|
||||
if (cardsInDeck.Count == 0)
|
||||
return;
|
||||
|
||||
var cardEntity = cardsInDeck.First();
|
||||
|
||||
// Remove from deck, add to hand.
|
||||
world.RemoveComponent<InDeck>(cardEntity);
|
||||
world.AddComponent(cardEntity, new Holds { Source = cardEntity, Target = handEntity });
|
||||
}
|
||||
|
||||
internal static Entity FindEntity<T>(World world, Entity singletonEntity)
|
||||
where T : struct
|
||||
{
|
||||
using var iter = world.Select<T>();
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != singletonEntity)
|
||||
return iter.CurrentEntity;
|
||||
}
|
||||
return Entity.Null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Starts a new round after the previous one ended.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct NewRoundCommand : ICommand
|
||||
{
|
||||
public void Execute(World world)
|
||||
{
|
||||
ref var state = ref world.GetSingleton<GameState>();
|
||||
|
||||
if (state.Phase != GamePhase.RoundOver)
|
||||
return;
|
||||
|
||||
// Clear hands from previous round.
|
||||
var singletonEntity = World.SingletonEntity;
|
||||
var handEntities = new List<Entity>();
|
||||
using (var iter = world.Select<PlayerHand>())
|
||||
{
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != singletonEntity)
|
||||
handEntities.Add(iter.CurrentEntity);
|
||||
}
|
||||
}
|
||||
using (var iter = world.Select<DealerHand>())
|
||||
{
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != singletonEntity)
|
||||
handEntities.Add(iter.CurrentEntity);
|
||||
}
|
||||
}
|
||||
foreach (var hand in handEntities)
|
||||
{
|
||||
var cards = world.GetSources<Holds>(hand);
|
||||
var deckEntity = HitCommand.FindEntity<Deck>(world, singletonEntity);
|
||||
foreach (var card in cards)
|
||||
{
|
||||
world.RemoveComponent<Holds>(card);
|
||||
world.AddComponent(card, new InDeck { Source = card, Target = deckEntity });
|
||||
}
|
||||
}
|
||||
|
||||
state.Phase = GamePhase.Betting;
|
||||
state.Result = RoundResult.None;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Places a bet and advances the game to the dealing phase.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct PlaceBetCommand : ICommand
|
||||
{
|
||||
[Key(0)] public int Amount;
|
||||
|
||||
public void Execute(World world)
|
||||
{
|
||||
ref var state = ref world.GetSingleton<GameState>();
|
||||
|
||||
if (state.Phase != GamePhase.Betting)
|
||||
return;
|
||||
|
||||
if (Amount < 1 || Amount > state.Chips)
|
||||
return;
|
||||
|
||||
state.CurrentBet = Amount;
|
||||
state.Chips -= Amount;
|
||||
state.Phase = GamePhase.Dealing;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Player stands: advance to the dealer's turn.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct StandCommand : ICommand
|
||||
{
|
||||
public void Execute(World world)
|
||||
{
|
||||
ref var state = ref world.GetSingleton<GameState>();
|
||||
|
||||
if (state.Phase != GamePhase.PlayerTurn)
|
||||
return;
|
||||
|
||||
state.Phase = GamePhase.DealerTurn;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// A playing card with a suit and rank.
|
||||
/// One entity per card.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct Card
|
||||
{
|
||||
[Key(0)] public Suit Suit;
|
||||
[Key(1)] public Rank Rank;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component marking the dealer's hand entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct DealerHand { }
|
||||
@@ -0,0 +1,9 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component marking the deck entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct Deck { }
|
||||
@@ -0,0 +1,15 @@
|
||||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Relationship from a hand entity to a card entity,
|
||||
/// representing that the hand holds this card.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct Holds : IRelationship
|
||||
{
|
||||
[Key(0)] public Entity Source { get; set; }
|
||||
[Key(1)] public Entity Target { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Relationship from a card entity to the deck entity,
|
||||
/// representing that the card is in the deck.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct InDeck : IRelationship
|
||||
{
|
||||
[Key(0)] public Entity Source { get; set; }
|
||||
[Key(1)] public Entity Target { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component marking the player's hand entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct PlayerHand { }
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Game.Blackjack;
|
||||
|
||||
public enum Rank : byte
|
||||
{
|
||||
Ace = 1,
|
||||
Two = 2,
|
||||
Three = 3,
|
||||
Four = 4,
|
||||
Five = 5,
|
||||
Six = 6,
|
||||
Seven = 7,
|
||||
Eight = 8,
|
||||
Nine = 9,
|
||||
Ten = 10,
|
||||
Jack = 11,
|
||||
Queen = 12,
|
||||
King = 13
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Game.Blackjack;
|
||||
|
||||
public enum Suit : byte
|
||||
{
|
||||
Clubs = 0,
|
||||
Diamonds = 1,
|
||||
Hearts = 2,
|
||||
Spades = 3
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator.
|
||||
/// Seed is stored in the ECS GameState singleton.
|
||||
/// </summary>
|
||||
public static class Mulberry32
|
||||
{
|
||||
/// <summary>
|
||||
/// Advances the state and returns a random float in [0, 1).
|
||||
/// </summary>
|
||||
public static float NextFloat(ref uint state)
|
||||
{
|
||||
state += 0x6D2B79F5u;
|
||||
uint z = state;
|
||||
z = (z ^ (z >> 15)) * (z | 1u);
|
||||
z ^= z + (z ^ (z >> 7)) * (z | 61u);
|
||||
return (z ^ (z >> 14)) / (float)uint.MaxValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the state and returns a random integer in [min, max].
|
||||
/// </summary>
|
||||
public static int NextInt(ref uint state, int min, int max)
|
||||
{
|
||||
return (int)(NextFloat(ref state) * (max - min + 1)) + min;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Game.Blackjack;
|
||||
|
||||
public enum GamePhase : byte
|
||||
{
|
||||
Betting = 0,
|
||||
Dealing = 1,
|
||||
PlayerTurn = 2,
|
||||
DealerTurn = 3,
|
||||
RoundOver = 4
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Global game state stored on the singleton entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct GameState
|
||||
{
|
||||
[Key(0)] public GamePhase Phase;
|
||||
[Key(1)] public RoundResult Result;
|
||||
[Key(2)] public int Chips;
|
||||
[Key(3)] public int CurrentBet;
|
||||
[Key(4)] public int RoundNumber;
|
||||
[Key(5)] public uint Seed;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Game.Blackjack;
|
||||
|
||||
public enum RoundResult : byte
|
||||
{
|
||||
None = 0,
|
||||
PlayerBust = 1,
|
||||
DealerBust = 2,
|
||||
PlayerWin = 3,
|
||||
DealerWin = 4,
|
||||
Push = 5,
|
||||
Blackjack = 6
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using OECS;
|
||||
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Deals initial two cards to player and dealer when entering the dealing phase.
|
||||
/// Advances to player turn after dealing.
|
||||
/// </summary>
|
||||
public class DealSystem : ISystem
|
||||
{
|
||||
public void Run(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.Dealing)
|
||||
return;
|
||||
|
||||
ref var mutableState = ref world.GetSingleton<GameState>();
|
||||
|
||||
// Deal two cards to player, then two to dealer.
|
||||
HitCommand.DrawCard<PlayerHand>(world);
|
||||
HitCommand.DrawCard<PlayerHand>(world);
|
||||
HitCommand.DrawCard<DealerHand>(world);
|
||||
HitCommand.DrawCard<DealerHand>(world);
|
||||
|
||||
mutableState.Phase = GamePhase.PlayerTurn;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PlayerHandTag { public static readonly PlayerHand Instance = new(); }
|
||||
internal static class DealerHandTag { public static readonly DealerHand Instance = new(); }
|
||||
@@ -0,0 +1,54 @@
|
||||
using OECS;
|
||||
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Dealer draws cards until reaching 17 or higher,
|
||||
/// then resolves the round result.
|
||||
/// </summary>
|
||||
public class DealerSystem : ISystem
|
||||
{
|
||||
public void Run(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.DealerTurn)
|
||||
return;
|
||||
|
||||
ref var mutableState = ref world.GetSingleton<GameState>();
|
||||
|
||||
// Dealer must hit on 16 and below, stand on 17+.
|
||||
int dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
|
||||
while (dealerTotal < 17)
|
||||
{
|
||||
HitCommand.DrawCard<DealerHand>(world);
|
||||
dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
|
||||
}
|
||||
|
||||
int playerTotal = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
|
||||
|
||||
mutableState.Phase = GamePhase.RoundOver;
|
||||
|
||||
if (dealerTotal > 21)
|
||||
{
|
||||
mutableState.Result = RoundResult.DealerBust;
|
||||
mutableState.Chips += mutableState.CurrentBet * 2;
|
||||
}
|
||||
else if (playerTotal > dealerTotal)
|
||||
{
|
||||
mutableState.Result = RoundResult.PlayerWin;
|
||||
mutableState.Chips += mutableState.CurrentBet * 2;
|
||||
}
|
||||
else if (dealerTotal > playerTotal)
|
||||
{
|
||||
mutableState.Result = RoundResult.DealerWin;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Push: return the bet.
|
||||
mutableState.Result = RoundResult.Push;
|
||||
mutableState.Chips += mutableState.CurrentBet;
|
||||
}
|
||||
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using OECS;
|
||||
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the deck (52 cards + deck/hand entities) and shuffles
|
||||
/// using mulberry32 with the seed from the GameState singleton.
|
||||
/// Runs once at the start of each round (during Dealing phase).
|
||||
/// </summary>
|
||||
public class DeckSetupSystem : ISystem
|
||||
{
|
||||
public void Run(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.Dealing)
|
||||
return;
|
||||
|
||||
// Only create deck entities if they don't exist yet.
|
||||
var singletonEntity = World.SingletonEntity;
|
||||
bool hasDeck = false;
|
||||
using (var iter = world.Select<Deck>())
|
||||
{
|
||||
hasDeck = iter.MoveNext() && iter.CurrentEntity != singletonEntity;
|
||||
}
|
||||
|
||||
if (!hasDeck)
|
||||
{
|
||||
// Create deck entity.
|
||||
var deckEntity = world.CreateEntity();
|
||||
world.AddComponent(deckEntity, new Deck());
|
||||
|
||||
// Create all 52 cards.
|
||||
foreach (Suit suit in Enum.GetValues<Suit>())
|
||||
{
|
||||
foreach (Rank rank in Enum.GetValues<Rank>())
|
||||
{
|
||||
var cardEntity = world.CreateEntity();
|
||||
world.AddComponent(cardEntity, new Card { Suit = suit, Rank = rank });
|
||||
world.AddComponent(cardEntity, new InDeck { Source = cardEntity, Target = deckEntity });
|
||||
}
|
||||
}
|
||||
|
||||
// Create hand entities.
|
||||
var playerHand = world.CreateEntity();
|
||||
world.AddComponent(playerHand, new PlayerHand());
|
||||
|
||||
var dealerHand = world.CreateEntity();
|
||||
world.AddComponent(dealerHand, new DealerHand());
|
||||
}
|
||||
|
||||
// Shuffle the deck using mulberry32 with the current seed.
|
||||
ref var mutableState = ref world.GetSingleton<GameState>();
|
||||
var deckEntity2 = HitCommand.FindEntity<Deck>(world, singletonEntity);
|
||||
var cards = world.GetSources<InDeck>(deckEntity2).ToArray();
|
||||
Shuffle(world, deckEntity2, cards, ref mutableState.Seed);
|
||||
}
|
||||
|
||||
private static void Shuffle(World world, Entity deckEntity, Entity[] cardEntities, ref uint seed)
|
||||
{
|
||||
// Fisher-Yates shuffle using mulberry32.
|
||||
for (int i = cardEntities.Length - 1; i > 0; i--)
|
||||
{
|
||||
int j = Mulberry32.NextInt(ref seed, 0, i);
|
||||
|
||||
// Swap the InDeck relationship targets (cards are always in the deck,
|
||||
// so there's nothing to swap except the cards themselves — but we
|
||||
// shuffle the card order conceptually by removing and re-adding
|
||||
// InDeck components in shuffled order). Actually, since InDeck
|
||||
// is just a tag, we just re-shuffle the order in the source collection.
|
||||
// The simplest approach: no need to swap component data; we just
|
||||
// need to ensure the cards are iterated in shuffled order.
|
||||
// We'll swap the card entities in the array.
|
||||
(cardEntities[i], cardEntities[j]) = (cardEntities[j], cardEntities[i]);
|
||||
}
|
||||
|
||||
// Now remove all InDeck and re-add in shuffled order so GetSources
|
||||
// returns them in shuffled order.
|
||||
for (int i = cardEntities.Length - 1; i >= 0; i--)
|
||||
{
|
||||
world.RemoveComponent<InDeck>(cardEntities[i]);
|
||||
}
|
||||
for (int i = 0; i < cardEntities.Length; i++)
|
||||
{
|
||||
world.AddComponent(cardEntities[i], new InDeck { Source = cardEntities[i], Target = deckEntity });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using OECS;
|
||||
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Helper to calculate the blackjack value of a hand.
|
||||
/// Aces count as 11 unless that would bust, then they count as 1.
|
||||
/// </summary>
|
||||
public static class HandUtil
|
||||
{
|
||||
public static int CalculateHand(World world, DealerHand hand)
|
||||
{
|
||||
return CalculateHandImpl(world, hand);
|
||||
}
|
||||
|
||||
public static int CalculateHand(World world, PlayerHand hand)
|
||||
{
|
||||
return CalculateHandImpl(world, hand);
|
||||
}
|
||||
|
||||
private static int CalculateHandImpl<T>(World world, T handTag)
|
||||
where T : struct
|
||||
{
|
||||
// Find the hand entity.
|
||||
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 0;
|
||||
|
||||
var cards = world.GetSources<Holds>(handEntity);
|
||||
int total = 0;
|
||||
int aceCount = 0;
|
||||
|
||||
foreach (var cardEntity in cards)
|
||||
{
|
||||
var card = world.ReadComponent<Card>(cardEntity);
|
||||
int value = card.Rank switch
|
||||
{
|
||||
Rank.Ace => 11,
|
||||
Rank.Jack => 10,
|
||||
Rank.Queen => 10,
|
||||
Rank.King => 10,
|
||||
_ => (int)card.Rank
|
||||
};
|
||||
|
||||
if (card.Rank == Rank.Ace)
|
||||
aceCount++;
|
||||
|
||||
total += value;
|
||||
}
|
||||
|
||||
// Downgrade aces from 11 to 1 as needed.
|
||||
while (total > 21 && aceCount > 0)
|
||||
{
|
||||
total -= 10;
|
||||
aceCount--;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using OECS;
|
||||
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the player's hand total after each hit.
|
||||
/// Busts the player if they exceed 21.
|
||||
/// </summary>
|
||||
public class PlayerBustCheckSystem : ISystem
|
||||
{
|
||||
public void Run(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.PlayerTurn)
|
||||
return;
|
||||
|
||||
var total = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
|
||||
if (total <= 21)
|
||||
return;
|
||||
|
||||
ref var mutableState = ref world.GetSingleton<GameState>();
|
||||
mutableState.Phase = GamePhase.RoundOver;
|
||||
mutableState.Result = RoundResult.PlayerBust;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user