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,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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Game.TicTacToe;
|
||||
|
||||
public enum Player : byte
|
||||
{
|
||||
None = 0,
|
||||
X = 1,
|
||||
O = 2
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
Row,Col
|
||||
0,0
|
||||
0,1
|
||||
0,2
|
||||
1,0
|
||||
1,1
|
||||
1,2
|
||||
2,0
|
||||
2,1
|
||||
2,2
|
||||
|
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Game.TicTacToe;
|
||||
|
||||
public enum GameStatus : byte
|
||||
{
|
||||
Playing = 0,
|
||||
XWon = 1,
|
||||
OWon = 2,
|
||||
Draw = 3
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user