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:
2026-07-20 16:41:10 +08:00
parent 7946f4bafd
commit b4661e714e
38 changed files with 578 additions and 34 deletions
@@ -0,0 +1,50 @@
using MessagePack;
using OECS;
namespace Game.TicTacToe;
/// <summary>
/// Places the current player's mark on the cell at (Row, Col).
/// Validates that the cell is empty and the game is still playing.
/// </summary>
[MessagePackObject]
public struct PlaceMarkCommand : ICommand
{
[Key(0)] public int Row;
[Key(1)] public int Col;
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Status != GameStatus.Playing)
return;
// Find the cell entity at (Row, Col) that has no Mark.
// Use the iterator API with early-exit via break.
var query = world.Query().With<Cell>().Without<Mark>().Build();
Entity? target = null;
using var iter = world.Select<Cell>(query);
while (iter.MoveNext())
{
if (iter.Current1.Row == Row && iter.Current1.Col == Col)
{
target = iter.CurrentEntity;
break;
}
}
if (target == null)
return; // Cell already occupied or invalid position.
// Place the mark. ComponentAdded is sufficient — MarkModified
// is only needed when mutating an existing component via GetComponent<T>.
world.AddComponent(target.Value, new Mark { Player = state.CurrentPlayer });
// Advance turn.
state.MoveCount++;
state.CurrentPlayer = state.CurrentPlayer == Player.X ? Player.O : Player.X;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+13
View File
@@ -0,0 +1,13 @@
using MessagePack;
namespace Game.TicTacToe;
/// <summary>
/// Identifies a board position. One entity per cell.
/// </summary>
[MessagePackObject]
public struct Cell
{
[Key(0)] public int Row;
[Key(1)] public int Col;
}
+13
View File
@@ -0,0 +1,13 @@
using MessagePack;
namespace Game.TicTacToe;
/// <summary>
/// A mark placed on a cell by a player.
/// Only present on cells that have been claimed.
/// </summary>
[MessagePackObject]
public struct Mark
{
[Key(0)] public Player Player;
}
+8
View File
@@ -0,0 +1,8 @@
namespace Game.TicTacToe;
public enum Player : byte
{
None = 0,
X = 1,
O = 2
}
+45
View File
@@ -0,0 +1,45 @@
using OECS;
namespace Game.TicTacToe;
/// <summary>
/// Minimal CSV loader that creates entities from a CSV file.
///
/// The first row is a header. Each subsequent row creates one entity.
/// Column names are mapped to component fields by convention:
/// a column named "Row" sets Cell.Row, "Col" sets Cell.Col, etc.
///
/// Currently hardcoded for the Cell component. Extend as needed.
/// </summary>
public static class CsvLoader
{
public static List<Entity> LoadCells(World world, string filePath)
{
var entities = new List<Entity>();
var lines = File.ReadAllLines(filePath);
if (lines.Length < 2)
return entities;
// Parse header to get column indices.
var headers = lines[0].Split(',');
int rowIdx = Array.IndexOf(headers, "Row");
int colIdx = Array.IndexOf(headers, "Col");
for (int i = 1; i < lines.Length; i++)
{
var values = lines[i].Split(',');
if (values.Length < 2) continue;
var entity = world.CreateEntity();
world.AddComponent(entity, new Cell
{
Row = int.Parse(values[rowIdx]),
Col = int.Parse(values[colIdx])
});
entities.Add(entity);
}
return entities;
}
}
+10
View File
@@ -0,0 +1,10 @@
Row,Col
0,0
0,1
0,2
1,0
1,1
1,2
2,0
2,1
2,2
1 Row Col
2 0 0
3 0 1
4 0 2
5 1 0
6 1 1
7 1 2
8 2 0
9 2 1
10 2 2
+14
View File
@@ -0,0 +1,14 @@
using MessagePack;
namespace Game.TicTacToe;
/// <summary>
/// Global game state stored on the singleton entity.
/// </summary>
[MessagePackObject]
public struct GameState
{
[Key(0)] public Player CurrentPlayer;
[Key(1)] public GameStatus Status;
[Key(2)] public int MoveCount;
}
+9
View File
@@ -0,0 +1,9 @@
namespace Game.TicTacToe;
public enum GameStatus : byte
{
Playing = 0,
XWon = 1,
OWon = 2,
Draw = 3
}
+85
View File
@@ -0,0 +1,85 @@
using OECS;
namespace Game.TicTacToe;
/// <summary>
/// After each move, checks whether the game has been won or drawn.
/// Updates the GameState singleton accordingly.
/// </summary>
public class WinCheckSystem : ISystem
{
public void Run(World world)
{
// Check game status before doing any work. Use ReadSingleton
// to avoid auto-marking GameState as modified when we bail early.
if (world.ReadSingleton<GameState>().Status != GameStatus.Playing)
return;
ref var state = ref world.GetSingleton<GameState>();
// Build a 3×3 grid of marks using the iterator API.
var grid = new Player[3, 3];
using var iter = world.Select<Cell, Mark>();
while (iter.MoveNext())
{
grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player;
}
// Check rows.
for (int r = 0; r < 3; r++)
{
if (TryGetWinner(grid[r, 0], grid[r, 1], grid[r, 2], out var winner))
{
SetWinner(world, ref state, winner);
return;
}
}
// Check columns.
for (int c = 0; c < 3; c++)
{
if (TryGetWinner(grid[0, c], grid[1, c], grid[2, c], out var winner))
{
SetWinner(world, ref state, winner);
return;
}
}
// Check diagonals.
if (TryGetWinner(grid[0, 0], grid[1, 1], grid[2, 2], out var diag1))
{
SetWinner(world, ref state, diag1);
return;
}
if (TryGetWinner(grid[0, 2], grid[1, 1], grid[2, 0], out var diag2))
{
SetWinner(world, ref state, diag2);
return;
}
// Check draw.
if (state.MoveCount >= 9)
{
state.Status = GameStatus.Draw;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
private static bool TryGetWinner(Player a, Player b, Player c, out Player winner)
{
if (a != Player.None && a == b && b == c)
{
winner = a;
return true;
}
winner = Player.None;
return false;
}
private static void SetWinner(World world, ref GameState state, Player winner)
{
state.Status = winner == Player.X ? GameStatus.XWon : GameStatus.OWon;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<RootNamespace>Game.TicTacToe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OECS\OECS.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<None Update="Data\board.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>