Create db and db access. Add json converter for board array. Add tests for json conversion

This commit is contained in:
jheaps
2025-07-04 13:41:48 -06:00
parent ff1a131a3c
commit 7a532e52e1
18 changed files with 461 additions and 132 deletions
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit.Analyzers" Version="3.9.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JoshHeaps.Net\JoshHeaps.Net.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
</Project>
@@ -0,0 +1,100 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Utilities;
using System.Text.Json;
namespace JoshHeaps.Net.Tests.JsonTests;
internal class JsonConversionTests
{
[Test]
public void ConvertBoard_WhenGivenStartingBoard_CanConvertToAndFromJson()
{
// Arrange
var gameState = new ChessService().CreateNewGame();
// Act
var serializedGameState = JsonSerializer.Serialize(gameState);
var deserializedGameState = JsonSerializer.Deserialize<GameState>(serializedGameState);
Assert.That(deserializedGameState, Is.Not.Null);
var originBoard = gameState.Board;
var convertedBoard = deserializedGameState!.Board;
// Assert
AssertBoardEquality(originBoard, convertedBoard);
}
[Test]
public void ConvertBoard_WhenGivenValidBoard_CanConvertToAndFromJson()
{
// Arrange
ChessPiece?[,] originBoard = new ChessPiece?[8,8];
originBoard[0, 0] = new ChessPiece(
"rook1",
PieceType.Rook,
PieceColor.White,
new Position(0, 0)
);
originBoard[4,4] = new ChessPiece(
"pawn1",
PieceType.Pawn,
PieceColor.Black,
new Position(4, 4)
);
// Act
var serializedBoard = JsonSerializer.Serialize(originBoard, _options);
var convertedBoard = JsonSerializer.Deserialize<ChessPiece?[,]>(serializedBoard, _options);
// Assert
AssertBoardEquality(originBoard, convertedBoard!);
}
private static void AssertBoardEquality(ChessPiece?[,]? original, ChessPiece?[,]? converted)
{
Assert.Multiple(() =>
{
Assert.That(original, Is.Not.Null);
Assert.That(converted, Is.Not.Null);
});
Assert.That(original!.LongLength, Is.EqualTo(converted!.LongLength));
for (int i = 0; i < original.GetLength(0); i++)
{
for (int j = 0; j < original.GetLength(1); j++)
{
var originalPiece = original[i, j];
var convertedPiece = converted[i, j];
AssertPieceEquality(originalPiece, convertedPiece);
}
}
}
private static void AssertPieceEquality(ChessPiece? original, ChessPiece? converted)
{
if (original is null)
{
Assert.That(converted, Is.Null);
return;
}
Assert.Multiple(() =>
{
Assert.That(converted, Is.Not.Null);
Assert.That(original!.Id, Is.EqualTo(converted!.Id));
Assert.That(original.Type, Is.EqualTo(converted.Type));
Assert.That(original.Color, Is.EqualTo(converted.Color));
Assert.That(original.Position.Row, Is.EqualTo(converted.Position.Row));
Assert.That(original.Position.Col, Is.EqualTo(converted.Position.Col));
});
}
private static readonly JsonSerializerOptions _options = new()
{
Converters = { new ChessBoardConverter() }
};
}
+6
View File
@@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{82206AD3-19DF-4DDA-9647-02B691B78CE8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net.Tests", "JoshHeaps.Net.Tests\JoshHeaps.Net.Tests.csproj", "{769155E1-1686-402C-9618-272D20DC3F96}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -21,6 +23,10 @@ Global
{82206AD3-19DF-4DDA-9647-02B691B78CE8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{82206AD3-19DF-4DDA-9647-02B691B78CE8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{82206AD3-19DF-4DDA-9647-02B691B78CE8}.Release|Any CPU.Build.0 = Release|Any CPU
{769155E1-1686-402C-9618-272D20DC3F96}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{769155E1-1686-402C-9618-272D20DC3F96}.Debug|Any CPU.Build.0 = Debug|Any CPU
{769155E1-1686-402C-9618-272D20DC3F96}.Release|Any CPU.ActiveCfg = Release|Any CPU
{769155E1-1686-402C-9618-272D20DC3F96}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+5 -1
View File
@@ -9,7 +9,10 @@ namespace JoshHeaps.Net.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ChessController(IChessService chessService, IHubContext<ChessHub> chessHub, IBackgroundTaskQueue queue) : ControllerBase
public class ChessController(
IChessService chessService,
IHubContext<ChessHub> chessHub,
IBackgroundTaskQueue queue) : ControllerBase
{
/// <summary>
/// Store of ongoing games.
@@ -31,6 +34,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
gameState.IsVsComputer = true;
gameState.WhiteJoined = true;
gameState.BlackJoined = true;
gameState.ComputerDifficulty = difficulty;
Guid playerId = Guid.NewGuid();
Guid computerId = Guid.NewGuid();
var isWhite = Random.Shared.Next(2) == 0;
+40
View File
@@ -0,0 +1,40 @@
using JoshHeaps.Net.Models;
using System.Text.Json;
namespace JoshHeaps.Net.DAL;
public class ChessDbAccess(ChessDbContext db)
{
private static readonly JsonSerializerOptions opts =
new(JsonSerializerDefaults.Web) { WriteIndented = false };
public async Task SaveAsync(GameState state, CancellationToken ct = default)
{
var json = JsonSerializer.Serialize(state, opts);
var entity = await db.Games.FindAsync([state.GameId], ct)
?? new GameStateEntity { GameId = state.GameId };
entity.SerializedState = json;
entity.LastMoveUtc = DateTime.UtcNow;
db.Update(entity);
await db.SaveChangesAsync(ct);
}
public async Task<GameState?> LoadAsync(Guid id, CancellationToken ct = default)
{
var e = await db.Games.FindAsync([id], ct);
return e is null
? null
: JsonSerializer.Deserialize<GameState>(e.SerializedState, opts);
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
if (await db.Games.FindAsync([id], ct) is { } e)
{
db.Remove(e);
await db.SaveChangesAsync(ct);
}
}
}
+23
View File
@@ -0,0 +1,23 @@
using JoshHeaps.Net.Models;
using Microsoft.EntityFrameworkCore;
namespace JoshHeaps.Net.DAL;
public class ChessDbContext : DbContext
{
public ChessDbContext(DbContextOptions<ChessDbContext> opts) : base(opts) { }
public DbSet<GameStateEntity> Games => Set<GameStateEntity>();
protected override void OnModelCreating(ModelBuilder b)
{
// Primary key
b.Entity<GameStateEntity>().HasKey(g => g.GameId);
// Board + pieces serialized as JSON blobs
b.Entity<GameStateEntity>()
.Property(g => g.SerializedState)
.HasColumnType("TEXT");
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8 -2
View File
@@ -7,7 +7,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.6" />
<PackageReference Include="System.Text.Json" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
@@ -17,6 +22,7 @@
</ItemGroup>
<ItemGroup>
<Folder Include="Databases\" />
<Folder Include="Resources\" />
<Folder Include="wwwroot\css\debug\" />
<Folder Include="wwwroot\images\Chess Images\" />
@@ -24,7 +30,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.6" />
</ItemGroup>
</Project>
+8
View File
@@ -1,4 +1,8 @@
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Utilities;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion.Internal;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
namespace JoshHeaps.Net.Models;
@@ -8,6 +12,7 @@ public class GameState
// 8x8 board of references. Null if no piece is present.
// row 0 at top -> row 7 at bottom (typical 0-based array).
[JsonConverter(typeof(ChessBoardConverter))]
public ChessPiece?[,] Board { get; set; }
// Whose turn is it?
@@ -42,7 +47,10 @@ public class GameState
public Guid BlackPlayerId { get; set; }
public bool IsVsComputer { get; set; } = false;
public int ComputerDifficulty { get; set; } = 20;
[JsonIgnore] // <-- exclude from JSON
[NotMapped] // <-- EF won't try to persist it either
public Stockfish? Computer { get; set; }
// optional: convenience
+8
View File
@@ -0,0 +1,8 @@
namespace JoshHeaps.Net.Models;
public class GameStateEntity
{
public Guid GameId { get; set; }
public string SerializedState { get; set; } = ""; // JSON
public DateTime LastMoveUtc { get; set; }
}
+10
View File
@@ -1,6 +1,8 @@
using JoshHeaps.Net.DAL;
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace JoshHeaps.Net;
@@ -27,9 +29,17 @@ public class Program
builder.Services.AddSingleton<IChessService, ChessService>();
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
var cs = builder.Configuration.GetConnectionString("ChessDatabase");
builder.Services.AddDbContext<ChessDbContext>(o => o.UseSqlite(cs));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ChessDbContext>();
dbContext.Database.Migrate();
}
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
@@ -1,11 +1,10 @@
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using JoshHeaps.Net.Utilities;
using Microsoft.AspNetCore.SignalR;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Channels;
namespace JoshHeaps.Net.Services.Implementations;
@@ -124,130 +123,4 @@ public sealed class Stockfish : IAsyncDisposable
await chessHub.Clients.Group(state.GameId.ToString()).SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), moveDto, result);
}
}
public static class StockfishHelpers
{
public static MoveDto ToMoveDto(
this string uci,
GameState gameState,
Guid playerId)
{
int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1');
int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1');
var piece = gameState.Board[fRow, fCol]
?? throw new Exception("No piece at source square");
PieceType? promo = uci.Length == 5 ? uci[4] switch
{
'q' => PieceType.Queen,
'r' => PieceType.Rook,
'b' => PieceType.Bishop,
'n' => PieceType.Knight,
_ => null
} : null;
return new MoveDto
{
GameId = gameState.GameId,
PlayerId = playerId,
PieceId = piece.Id,
TargetRow = tRow,
TargetCol = tCol,
PromotionChoice = promo,
SourceCol = fCol,
SourceRow = fRow,
};
}
/// <summary>
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
/// Only piece placement + active colour + castling are computed; the rest use
/// safe defaults (-, 0, 1). That is all Stockfish needs.
/// </summary>
public static string ToFen(this GameState gs)
{
var sb = new StringBuilder(64);
/* 1) piece placement */
for (int row = 0; row < 8; row++)
{
int empty = 0;
for (int col = 0; col < 8; col++)
{
var p = gs.Board[row, col];
if (p is null)
{
empty++;
}
else
{
if (empty > 0) { sb.Append(empty); empty = 0; }
sb.Append(ToFenChar(p)); // ← unchanged helper
}
}
if (empty > 0) sb.Append(empty);
if (row < 7) sb.Append('/');
}
/* 2) active colour */
sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b ");
/* 3) castling rights (from GameState flags) */
sb.Append(GetCastlingFlags(gs));
/* 4) en-passant target square */
sb.Append(' ');
sb.Append(gs.EnPassantTarget.HasValue
? Alg(gs.EnPassantTarget.Value)
: "-");
/* 5-6) half-move clock + full-move number */
int fullMoves = gs.MoveHistory.Count / 2 + 1;
sb.Append(" 0 ").Append(fullMoves);
return sb.ToString();
}
/* ---------- helpers ---------- */
private static string GetCastlingFlags(GameState gs)
{
var flags = new StringBuilder(4);
if (gs.WhiteCanCastleKingside) flags.Append('K');
if (gs.WhiteCanCastleQueenside) flags.Append('Q');
if (gs.BlackCanCastleKingside) flags.Append('k');
if (gs.BlackCanCastleQueenside) flags.Append('q');
return flags.Length == 0 ? "-" : flags.ToString();
}
private static string Alg(Position p)
{
char file = (char)('a' + p.Col);
int rank = 8 - p.Row;
return $"{file}{rank}";
}
private static char ToFenChar(ChessPiece p) => p switch
{
{ Type: PieceType.Pawn, Color: PieceColor.White } => 'P',
{ Type: PieceType.Pawn, Color: PieceColor.Black } => 'p',
{ Type: PieceType.Knight, Color: PieceColor.White } => 'N',
{ Type: PieceType.Knight, Color: PieceColor.Black } => 'n',
{ Type: PieceType.Bishop, Color: PieceColor.White } => 'B',
{ Type: PieceType.Bishop, Color: PieceColor.Black } => 'b',
{ Type: PieceType.Rook, Color: PieceColor.White } => 'R',
{ Type: PieceType.Rook, Color: PieceColor.Black } => 'r',
{ Type: PieceType.Queen, Color: PieceColor.White } => 'Q',
{ Type: PieceType.Queen, Color: PieceColor.Black } => 'q',
{ Type: PieceType.King, Color: PieceColor.White } => 'K',
{ Type: PieceType.King, Color: PieceColor.Black } => 'k',
_ => throw new ArgumentOutOfRangeException(nameof(p))
};
}
@@ -0,0 +1,80 @@
using JoshHeaps.Net.Models;
using System.Text.Json.Serialization;
using System.Text.Json;
namespace JoshHeaps.Net.Utilities;
public sealed class ChessBoardConverter
: JsonConverter<ChessPiece?[,]>
{
public override ChessPiece?[,] Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
List<ChessPiece?> pieces = [];
if (reader.TokenType != JsonTokenType.StartArray)
throw new JsonException($"Expected start of an array, instead received {reader.TokenType}");
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (reader.TokenType == JsonTokenType.Null)
{
pieces.Add(null);
continue;
}
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException($"Expected start of an object, instead received {reader.TokenType}");
var piece = JsonSerializer.Deserialize<ChessPiece>(ref reader, options) ?? throw new JsonException("Deserialized piece was null");
pieces.Add(piece);
if (reader.TokenType != JsonTokenType.EndObject)
throw new JsonException($"Expected end of an object, instead received {reader.TokenType}");
}
if (reader.TokenType != JsonTokenType.EndArray)
throw new JsonException($"Expected end of an array, instead received {reader.TokenType}");
var boardSize = Math.Sqrt(pieces.Count);
if (boardSize % 1 != 0)
throw new JsonException($"Expected a square number of pieces, instead received {pieces.Count}");
ChessPiece?[,] board = new ChessPiece?[(int)boardSize, (int)boardSize];
foreach (var piece in pieces)
{
if (piece is null)
continue;
board[piece.Position.Row, piece.Position.Col] = piece;
}
return board;
}
public override void Write(
Utf8JsonWriter writer,
ChessPiece?[,] value,
JsonSerializerOptions options)
{
writer.WriteStartArray();
foreach (var piece in value)
{
if (piece is null)
{
writer.WriteNullValue();
continue;
}
JsonSerializer.Serialize(writer, piece);
}
writer.WriteEndArray();
}
}
+130
View File
@@ -0,0 +1,130 @@
using JoshHeaps.Net.Models;
using System.Text;
namespace JoshHeaps.Net.Utilities;
public static class StockfishHelpers
{
public static MoveDto ToMoveDto(
this string uci,
GameState gameState,
Guid playerId)
{
int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1');
int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1');
var piece = gameState.Board[fRow, fCol]
?? throw new Exception("No piece at source square");
PieceType? promo = uci.Length == 5 ? uci[4] switch
{
'q' => PieceType.Queen,
'r' => PieceType.Rook,
'b' => PieceType.Bishop,
'n' => PieceType.Knight,
_ => null
} : null;
return new MoveDto
{
GameId = gameState.GameId,
PlayerId = playerId,
PieceId = piece.Id,
TargetRow = tRow,
TargetCol = tCol,
PromotionChoice = promo,
SourceCol = fCol,
SourceRow = fRow,
};
}
/// <summary>
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
/// Only piece placement + active colour + castling are computed; the rest use
/// safe defaults (-, 0, 1). That is all Stockfish needs.
/// </summary>
public static string ToFen(this GameState gs)
{
var sb = new StringBuilder(64);
/* 1) piece placement */
for (int row = 0; row < 8; row++)
{
int empty = 0;
for (int col = 0; col < 8; col++)
{
var p = gs.Board[row, col];
if (p is null)
{
empty++;
}
else
{
if (empty > 0) { sb.Append(empty); empty = 0; }
sb.Append(ToFenChar(p)); // ← unchanged helper
}
}
if (empty > 0) sb.Append(empty);
if (row < 7) sb.Append('/');
}
/* 2) active colour */
sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b ");
/* 3) castling rights (from GameState flags) */
sb.Append(GetCastlingFlags(gs));
/* 4) en-passant target square */
sb.Append(' ');
sb.Append(gs.EnPassantTarget.HasValue
? Alg(gs.EnPassantTarget.Value)
: "-");
/* 5-6) half-move clock + full-move number */
int fullMoves = gs.MoveHistory.Count / 2 + 1;
sb.Append(" 0 ").Append(fullMoves);
return sb.ToString();
}
/* ---------- helpers ---------- */
private static string GetCastlingFlags(GameState gs)
{
var flags = new StringBuilder(4);
if (gs.WhiteCanCastleKingside) flags.Append('K');
if (gs.WhiteCanCastleQueenside) flags.Append('Q');
if (gs.BlackCanCastleKingside) flags.Append('k');
if (gs.BlackCanCastleQueenside) flags.Append('q');
return flags.Length == 0 ? "-" : flags.ToString();
}
private static string Alg(Position p)
{
char file = (char)('a' + p.Col);
int rank = 8 - p.Row;
return $"{file}{rank}";
}
private static char ToFenChar(ChessPiece p) => p switch
{
{ Type: PieceType.Pawn, Color: PieceColor.White } => 'P',
{ Type: PieceType.Pawn, Color: PieceColor.Black } => 'p',
{ Type: PieceType.Knight, Color: PieceColor.White } => 'N',
{ Type: PieceType.Knight, Color: PieceColor.Black } => 'n',
{ Type: PieceType.Bishop, Color: PieceColor.White } => 'B',
{ Type: PieceType.Bishop, Color: PieceColor.Black } => 'b',
{ Type: PieceType.Rook, Color: PieceColor.White } => 'R',
{ Type: PieceType.Rook, Color: PieceColor.Black } => 'r',
{ Type: PieceType.Queen, Color: PieceColor.White } => 'Q',
{ Type: PieceType.Queen, Color: PieceColor.Black } => 'q',
{ Type: PieceType.King, Color: PieceColor.White } => 'K',
{ Type: PieceType.King, Color: PieceColor.Black } => 'k',
_ => throw new ArgumentOutOfRangeException(nameof(p))
};
}
+4 -1
View File
@@ -5,5 +5,8 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"ConnectionStrings": {
"ChessDatabase": "Data Source=Databases/chess.db"
}
}
+10
View File
@@ -7,4 +7,14 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.6" />
<PackageReference Include="NUnit" Version="4.3.2" />
<PackageReference Include="System.Text.Json" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JoshHeaps.Net\JoshHeaps.Net.csproj" />
</ItemGroup>
</Project>