add custom chess engine

This commit is contained in:
Josh-Heaps
2026-06-08 13:58:22 -06:00
parent d2ae34f530
commit c3280aa6d3
42 changed files with 3033 additions and 168 deletions
@@ -0,0 +1,32 @@
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.Extensions.Options;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>The available chess engine implementations.</summary>
public enum ChessEngineKind
{
Stockfish,
Custom
}
/// <summary>Configuration selecting which <see cref="IChessEngine"/> to use.</summary>
public sealed class ChessEngineOptions
{
public const string SectionName = "ChessEngine";
public ChessEngineKind Engine { get; set; } = ChessEngineKind.Stockfish;
}
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
public sealed class ChessEngineFactory(IOptions<ChessEngineOptions> options) : IChessEngineFactory
{
private readonly ChessEngineKind _kind = options.Value.Engine;
public IChessEngine Create(int skill) => _kind switch
{
ChessEngineKind.Custom => new CustomChessEngine(skill),
ChessEngineKind.Stockfish => new Stockfish(skill),
_ => throw new InvalidOperationException($"Unknown chess engine '{_kind}'.")
};
}
@@ -0,0 +1,130 @@
using JoshHeaps.Net.Models;
using System.Text;
namespace JoshHeaps.Net.Services.Implementations;
public static class ChessEngineHelpers
{
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 the engine 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,33 @@
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.SignalR;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Engine-agnostic glue for computer moves: get a move from the engine, apply it
/// through the rules service, and broadcast it to the game's clients.
/// </summary>
public sealed class ComputerMoveOrchestrator(
IHubContext<ChessHub> chessHub,
IChessService chessService) : IComputerMoveOrchestrator
{
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine)
{
var uci = await engine.GetBestMoveAsync(state.ToFen());
var move = uci.ToMoveDto(
state,
state.CurrentPlayer == PieceColor.White
? state.WhitePlayerId
: state.BlackPlayerId);
var result = chessService.MakeMove(state, move);
await chessHub.Clients.Group(state.GameId.ToString())
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result);
return (move, result);
}
}
@@ -0,0 +1,118 @@
using JoshHeaps.Net.Services.Interfaces;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Middleman wrapper over the native custom chess engine (chess_engine.dll / libchess_engine.so).
/// Shares <see cref="IChessEngine"/> with <see cref="Stockfish"/> so the two are swappable.
/// The boundary contract is FEN string in, UCI move string out — identical to Stockfish.
/// </summary>
public sealed partial class CustomChessEngine : IChessEngine
{
private readonly EngineSafeHandle _handle;
public int Skill { get; }
public CustomChessEngine(int skill = 20)
{
Skill = skill;
var handle = NativeMethods.engine_create($"skill={skill}");
if (handle == IntPtr.Zero)
throw new InvalidOperationException("Native chess engine failed to initialize (engine_create returned null).");
_handle = new EngineSafeHandle(handle);
}
public Task<string> GetBestMoveAsync(string fen) => Task.Run(() => GetBestMove(fen));
private unsafe string GetBestMove(string fen)
{
const int bufferLength = 16; // longest UCI move is 5 chars ("e7e8q") + NUL
byte* buffer = stackalloc byte[bufferLength];
var added = false;
try
{
_handle.DangerousAddRef(ref added);
var code = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, buffer, bufferLength);
if (code != 0)
throw new InvalidOperationException($"Native chess engine failed to produce a move for FEN '{fen}' (engine_best_move returned {code}).");
return Marshal.PtrToStringUTF8((IntPtr)buffer)
?? throw new InvalidOperationException("Native chess engine returned an empty move.");
}
finally
{
if (added)
_handle.DangerousRelease();
}
}
public ValueTask DisposeAsync()
{
_handle.Dispose();
return ValueTask.CompletedTask;
}
/// <summary>Guarantees the native handle is released exactly once via engine_destroy.</summary>
private sealed class EngineSafeHandle : SafeHandle
{
public EngineSafeHandle(IntPtr handle) : base(IntPtr.Zero, ownsHandle: true) => SetHandle(handle);
public override bool IsInvalid => handle == IntPtr.Zero;
protected override bool ReleaseHandle()
{
NativeMethods.engine_destroy(handle);
return true;
}
}
/// <summary>
/// P/Invoke surface for chess_engine.(dll|so). The resolver maps the logical name
/// "chess_engine" to the platform binary in the Resources folder (mirrors Stockfish).
/// </summary>
private static partial class NativeMethods
{
private const string LibName = "chess_engine";
static NativeMethods() =>
NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, Resolve);
private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
{
if (libraryName != LibName)
return IntPtr.Zero;
var fileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "chess_engine.dll"
: "libchess_engine.so";
var path = Path.Combine(AppContext.BaseDirectory, "Resources", fileName);
if (NativeLibrary.TryLoad(path, out var handle))
return handle;
return NativeLibrary.Load(libraryName, assembly, searchPath);
}
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial IntPtr engine_create(string? options);
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static unsafe partial int engine_best_move(IntPtr engine, string fen, byte* outBuffer, int outLength);
[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void engine_destroy(IntPtr engine);
}
}
@@ -1,22 +1,19 @@
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
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;
public sealed class Stockfish : IAsyncDisposable
public sealed class Stockfish : IChessEngine
{
private readonly Process _p;
private readonly StreamWriter _stdin;
private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>();
private readonly int _skill;
public int Skill => _skill;
public Stockfish(int skill = 20, int hash = 256)
{
_skill = skill;
@@ -28,13 +25,13 @@ public sealed class Stockfish : IAsyncDisposable
: "stockfish-ubuntu-x86-64-sse41-popcnt"; // default: Linux
string exePath = Path.Combine(baseDir, "Resources", fileName);
if (!File.Exists(exePath))
{
throw new FileNotFoundException($"Stockfish executable not found at {exePath}. " +
"Ensure the file is present in the Resources folder of your project.");
}
Console.Write(exePath);
_p = new Process
@@ -109,145 +106,4 @@ public sealed class Stockfish : IAsyncDisposable
await _p.WaitForExitAsync();
_p.Dispose();
}
public async Task MakeMove(GameState state, IHubContext<ChessHub> chessHub, IChessService chessService)
{
var move = await GetBestMoveAsync(state.ToFen());
var moveDto = move.ToMoveDto(
state,
state.CurrentPlayer == PieceColor.White
? state.WhitePlayerId
: state.BlackPlayerId);
var result = chessService.MakeMove(state, moveDto);
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))
};
}