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:
parent
7946f4bafd
commit
b4661e714e
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Game.Blackjack.Tests</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Game.Blackjack\Blackjack.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -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<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();
|
||||
|
||||
var chipsBefore = world.ReadSingleton<GameState>().Chips;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// ── 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
|
||||
{
|
||||
using var iter = world.Select<T>();
|
||||
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<Holds>(handEntity).Count;
|
||||
}
|
||||
|
||||
private static List<(Suit, Rank)> GetAllCards(World world)
|
||||
{
|
||||
var cards = new List<(Suit, Rank)>();
|
||||
var query = world.Query().With<Card>().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;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<RootNamespace>Blackjack</RootNamespace>
|
||||
<RootNamespace>Game.Blackjack</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Player hits: draws one card from the deck to the player's hand.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Starts a new round after the previous one ended.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Places a bet and advances the game to the dealing phase.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Player stands: advance to the dealer's turn.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// A playing card with a suit and rank.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component marking the dealer's hand entity.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component marking the deck entity.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Relationship from a hand entity to a card entity,
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Relationship from a card entity to the deck entity,
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component marking the player's hand entity.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
public enum Rank : byte
|
||||
{
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
public enum Suit : byte
|
||||
{
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
public enum GamePhase : byte
|
||||
{
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Global game state stored on the singleton entity.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
public enum RoundResult : byte
|
||||
{
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using OECS;
|
||||
|
||||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Deals initial two cards to player and dealer when entering the dealing phase.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using OECS;
|
||||
|
||||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Dealer draws cards until reaching 17 or higher,
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using OECS;
|
||||
|
||||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the deck (52 cards + deck/hand entities) and shuffles
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using OECS;
|
||||
|
||||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Helper to calculate the blackjack value of a hand.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using OECS;
|
||||
|
||||
namespace Blackjack;
|
||||
namespace Game.Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the player's hand total after each hit.
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Game.TicTacToe.Tests</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Game.TicTacToe\TicTacToe.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -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<Cell>().Without<Mark>().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<Cell>().With<Mark>().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<GameState>().CurrentPlayer.Should().Be(Player.O);
|
||||
|
||||
world.Commands.Enqueue(new PlaceMarkCommand { Row = 1, Col = 0 }); // O
|
||||
group.RunLogical();
|
||||
world.ReadSingleton<GameState>().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<Cell>().With<Mark>().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<GameState>().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<GameState>().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<GameState>().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<GameState>().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<GameState>().Status.Should().Be(GameStatus.XWon);
|
||||
|
||||
// Try to place another mark — should be ignored.
|
||||
var moveCountBefore = world.ReadSingleton<GameState>().MoveCount;
|
||||
world.Commands.Enqueue(new PlaceMarkCommand { Row = 2, Col = 2 });
|
||||
group.RunLogical();
|
||||
|
||||
world.ReadSingleton<GameState>().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<GameState>();
|
||||
state.CurrentPlayer.Should().Be(Player.X);
|
||||
state.MoveCount.Should().Be(2);
|
||||
|
||||
var query = world2.Query().With<Cell>().With<Mark>().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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace TicTacToe;
|
||||
namespace Game.TicTacToe;
|
||||
|
||||
/// <summary>
|
||||
/// Places the current player's mark on the cell at (Row, Col).
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace TicTacToe;
|
||||
namespace Game.TicTacToe;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies a board position. One entity per cell.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace TicTacToe;
|
||||
namespace Game.TicTacToe;
|
||||
|
||||
/// <summary>
|
||||
/// A mark placed on a cell by a player.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace TicTacToe;
|
||||
namespace Game.TicTacToe;
|
||||
|
||||
public enum Player : byte
|
||||
{
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using OECS;
|
||||
|
||||
namespace TicTacToe;
|
||||
namespace Game.TicTacToe;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal CSV loader that creates entities from a CSV file.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace TicTacToe;
|
||||
namespace Game.TicTacToe;
|
||||
|
||||
/// <summary>
|
||||
/// Global game state stored on the singleton entity.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
namespace TicTacToe;
|
||||
namespace Game.TicTacToe;
|
||||
|
||||
public enum GameStatus : byte
|
||||
{
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using OECS;
|
||||
|
||||
namespace TicTacToe;
|
||||
namespace Game.TicTacToe;
|
||||
|
||||
/// <summary>
|
||||
/// After each move, checks whether the game has been won or drawn.
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<RootNamespace>TicTacToe</RootNamespace>
|
||||
<RootNamespace>Game.TicTacToe</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
33
OECS.sln
33
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
|
||||
|
|
@ -82,6 +86,31 @@ Global
|
|||
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
{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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue