Add stockfish to chess

This commit is contained in:
jheaps
2025-07-03 13:20:53 -06:00
parent a541127f11
commit d799b88403
10 changed files with 381 additions and 75 deletions
@@ -0,0 +1,21 @@
using JoshHeaps.Net.Services.Interfaces;
using System.Collections.Concurrent;
using System.Threading.Channels;
namespace JoshHeaps.Net.Services.Implementations;
public class BackgroundTaskQueue : IBackgroundTaskQueue
{
private readonly ConcurrentDictionary<int, Task> _runningTasks = new();
public void Queue(Func<Task> workItem)
{
var task = Task.Run(workItem);
_runningTasks.TryAdd(task.Id, task);
task.ContinueWith(t => _runningTasks.TryRemove(t.Id, out _), TaskScheduler.Default);
}
public IReadOnlyCollection<Task> Running => [.. _runningTasks.Values];
public Task WhenAllDone() => Task.WhenAll(Running);
}
@@ -1,4 +1,7 @@
using JoshHeaps.Net.Models;
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.Text;
@@ -11,13 +14,15 @@ public sealed class Stockfish : IAsyncDisposable
private readonly Process _p;
private readonly StreamWriter _stdin;
private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>();
private readonly int _skill;
public Stockfish(int skill = 20, int hash = 256)
{
string relativeFilePath = @"JoshHeaps.Net\Resources\stockfish-windows-x86-64-avx2.exe";
string exePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!
.Split("JoshHeaps.Net")
.First() + relativeFilePath);
_skill = skill;
string relativeFilePath = @"\Resources\stockfish-windows-x86-64-avx2.exe";
string exePath = Assembly.GetExecutingAssembly().Location.Split(@"\bin\")[0] + relativeFilePath;
Console.Write(exePath);
_p = new Process
{
@@ -50,10 +55,10 @@ public sealed class Stockfish : IAsyncDisposable
WaitFor("readyok").GetAwaiter().GetResult();
}
public async Task<string> GetBestMoveAsync(string fen, int millis = 1000)
public async Task<string> GetBestMoveAsync(string fen)
{
Send($"position fen {fen}");
Send($"go movetime {millis}");
Send($"go depth {_skill}");
string? best = null;
await foreach (var line in _stdout.Reader.ReadAllAsync())
@@ -82,32 +87,75 @@ 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(
ChessPiece?[,] board,
PieceColor activeColour = PieceColor.White)
public static string ToFen(this GameState gs)
{
if (board.GetLength(0) != 8 || board.GetLength(1) != 8)
throw new ArgumentException("Board must be 8×8.");
var sb = new StringBuilder(64);
// ----- 1) piece placement -----
for (int rank = 0; rank < 8; rank++)
/* 1) piece placement */
for (int row = 0; row < 8; row++)
{
int empty = 0;
for (int file = 0; file < 8; file++)
for (int col = 0; col < 8; col++)
{
var p = board[rank, file];
var p = gs.Board[row, col];
if (p is null)
{
@@ -116,28 +164,54 @@ public static class StockfishHelpers
else
{
if (empty > 0) { sb.Append(empty); empty = 0; }
sb.Append(ToFenChar(p));
sb.Append(ToFenChar(p)); // ← unchanged helper
}
}
if (empty > 0) sb.Append(empty);
if (rank < 7) sb.Append('/');
if (row < 7) sb.Append('/');
}
// ----- 2) active colour -----
sb.Append(activeColour == PieceColor.White ? " w " : " b ");
/* 2) active colour */
sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b ");
// ----- 3) castling rights (simple check of corner rooks + kings) -----
sb.Append(GetCastlingFlags(board));
/* 3) castling rights (from GameState flags) */
sb.Append(GetCastlingFlags(gs));
// ----- 4-6) en-passant, half-move, full-move -----
sb.Append(" - 0 1"); // en-passant target; clocks
/* 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',
@@ -154,26 +228,4 @@ public static class StockfishHelpers
{ Type: PieceType.King, Color: PieceColor.Black } => 'k',
_ => throw new ArgumentOutOfRangeException(nameof(p))
};
private static string GetCastlingFlags(ChessPiece?[,] b)
{
// Fast lookup helpers
ChessPiece? A1 = b[7, 0], H1 = b[7, 7], E1 = b[7, 4];
ChessPiece? A8 = b[0, 0], H8 = b[0, 7], E8 = b[0, 4];
var flags = new StringBuilder(4);
if (E1 is { Type: PieceType.King, Color: PieceColor.White, HasMoved: false })
{
if (H1 is { Type: PieceType.Rook, Color: PieceColor.White, HasMoved: false }) flags.Append('K');
if (A1 is { Type: PieceType.Rook, Color: PieceColor.White, HasMoved: false }) flags.Append('Q');
}
if (E8 is { Type: PieceType.King, Color: PieceColor.Black, HasMoved: false })
{
if (H8 is { Type: PieceType.Rook, Color: PieceColor.Black, HasMoved: false }) flags.Append('k');
if (A8 is { Type: PieceType.Rook, Color: PieceColor.Black, HasMoved: false }) flags.Append('q');
}
return flags.Length == 0 ? "-" : flags.ToString();
}
}