Feature/custom chess engine #2

Closed
jheaps wants to merge 17 commits from feature/customChessEngine into AddProject
7 changed files with 47 additions and 8 deletions
Showing only changes of commit d87cea572f - Show all commits
+4
View File
@@ -40,6 +40,10 @@ public class GameState
// Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection. // Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection.
public List<string> PositionHistory { get; set; } public List<string> PositionHistory { get; set; }
// Half-moves since the last capture or pawn move (the FEN 50-move clock). Also tells
// us how many trailing PositionHistory entries belong to the current repetition window.
public int HalfmoveClock { get; set; }
// A list of all pieces to quickly reference them (optional but convenient). // A list of all pieces to quickly reference them (optional but convenient).
// Alternatively, you can iterate the Board array. // Alternatively, you can iterate the Board array.
public List<ChessPiece> Pieces { get; set; } public List<ChessPiece> Pieces { get; set; }
@@ -85,11 +85,34 @@ public static class ChessEngineHelpers
/* 5-6) half-move clock + full-move number */ /* 5-6) half-move clock + full-move number */
int fullMoves = gs.MoveHistory.Count / 2 + 1; int fullMoves = gs.MoveHistory.Count / 2 + 1;
sb.Append(" 0 ").Append(fullMoves); sb.Append(' ').Append(gs.HalfmoveClock).Append(' ').Append(fullMoves);
return sb.ToString(); return sb.ToString();
} }
/// <summary>
/// The prior positions since the last irreversible move (capture/pawn move), as
/// completed FEN strings, oldest first and excluding the current position. This is
/// the repetition window the engine needs to detect threefold/50-move draws that a
/// single FEN can't express.
/// </summary>
public static IReadOnlyList<string> RepetitionHistory(this GameState gs)
{
int clock = gs.HalfmoveClock;
int count = gs.PositionHistory.Count;
int start = count - 1 - clock; // PositionHistory ends with the current position
if (clock <= 0 || start < 0)
return Array.Empty<string>();
var fens = new List<string>(clock);
for (int i = start; i < count - 1; i++)
fens.Add(gs.PositionHistory[i] + " 0 1"); // complete the 4-field key into a parseable FEN
return fens;
}
/* ---------- helpers ---------- */ /* ---------- helpers ---------- */
private static string GetCastlingFlags(GameState gs) private static string GetCastlingFlags(GameState gs)
@@ -28,6 +28,7 @@ public class ChessService : IChessService
gameState.BlackCanCastleKingside = true; gameState.BlackCanCastleKingside = true;
gameState.BlackCanCastleQueenside = true; gameState.BlackCanCastleQueenside = true;
gameState.EnPassantTarget = null; gameState.EnPassantTarget = null;
gameState.HalfmoveClock = 0;
SetupBlackPieces(gameState); SetupBlackPieces(gameState);
SetupWhitePieces(gameState); SetupWhitePieces(gameState);
@@ -178,6 +179,7 @@ public class ChessService : IChessService
{ {
var oldPos = piece.Position; var oldPos = piece.Position;
var captured = gs.Board[targetPos.Row, targetPos.Col]; var captured = gs.Board[targetPos.Row, targetPos.Col];
var isPawnMove = piece.Type == PieceType.Pawn; // captured before promotion can change Type
HandleEnPassantIfNeeded(gs, piece, targetPos, ref captured); HandleEnPassantIfNeeded(gs, piece, targetPos, ref captured);
@@ -199,6 +201,9 @@ public class ChessService : IChessService
UpdateCastlingRights(gs, piece, oldPos); UpdateCastlingRights(gs, piece, oldPos);
// Reset the 50-move clock on captures and pawn moves (irreversible); else advance it.
gs.HalfmoveClock = (isPawnMove || captured != null) ? 0 : gs.HalfmoveClock + 1;
gs.CurrentPlayer = gs.CurrentPlayer == PieceColor.White gs.CurrentPlayer = gs.CurrentPlayer == PieceColor.White
? PieceColor.Black ? PieceColor.Black
: PieceColor.White; : PieceColor.White;
@@ -15,7 +15,7 @@ public sealed class ComputerMoveOrchestrator(
{ {
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine) public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine)
{ {
var uci = await engine.GetBestMoveAsync(state.ToFen()); var uci = await engine.GetBestMoveAsync(state.ToFen(), state.RepetitionHistory());
var move = uci.ToMoveDto( var move = uci.ToMoveDto(
state, state,
@@ -28,9 +28,10 @@ public sealed partial class CustomChessEngine : IChessEngine
_handle = new EngineSafeHandle(handle); _handle = new EngineSafeHandle(handle);
} }
public Task<string> GetBestMoveAsync(string fen) => Task.Run(() => GetBestMove(fen)); public Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens) =>
Task.Run(() => GetBestMove(fen, string.Join('\n', historyFens)));
private unsafe string GetBestMove(string fen) private unsafe string GetBestMove(string fen, string history)
{ {
const int bufferLength = 16; // longest UCI move is 5 chars ("e7e8q") + NUL const int bufferLength = 16; // longest UCI move is 5 chars ("e7e8q") + NUL
byte* buffer = stackalloc byte[bufferLength]; byte* buffer = stackalloc byte[bufferLength];
@@ -40,7 +41,7 @@ public sealed partial class CustomChessEngine : IChessEngine
try try
{ {
_handle.DangerousAddRef(ref added); _handle.DangerousAddRef(ref added);
var code = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, buffer, bufferLength); var code = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, history, buffer, bufferLength);
if (code != 0) if (code != 0)
throw new InvalidOperationException($"Native chess engine failed to produce a move for FEN '{fen}' (engine_best_move returned {code})."); throw new InvalidOperationException($"Native chess engine failed to produce a move for FEN '{fen}' (engine_best_move returned {code}).");
@@ -109,7 +110,7 @@ public sealed partial class CustomChessEngine : IChessEngine
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static unsafe partial int engine_best_move(IntPtr engine, string fen, byte* outBuffer, int outLength); internal static unsafe partial int engine_best_move(IntPtr engine, string fen, string history, byte* outBuffer, int outLength);
[LibraryImport(LibName)] [LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
@@ -74,8 +74,9 @@ public sealed class Stockfish : IChessEngine
WaitFor("readyok").GetAwaiter().GetResult(); WaitFor("readyok").GetAwaiter().GetResult();
} }
public async Task<string> GetBestMoveAsync(string fen) public async Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens)
{ {
// historyFens is unused: Stockfish tracks repetition from the position it's given.
Send($"position fen {fen}"); Send($"position fen {fen}");
Send($"go depth {_skill}"); Send($"go depth {_skill}");
string? best = null; string? best = null;
@@ -14,6 +14,11 @@ public interface IChessEngine : IAsyncDisposable
/// Returns the engine's chosen move in UCI long-algebraic form (e.g. "e2e4", "e7e8q"). /// Returns the engine's chosen move in UCI long-algebraic form (e.g. "e2e4", "e7e8q").
/// </summary> /// </summary>
/// <param name="fen">The current position as a FEN string.</param> /// <param name="fen">The current position as a FEN string.</param>
/// <param name="historyFens">
/// Prior positions since the last irreversible move, oldest first, excluding the
/// current one — lets the engine detect threefold/50-move draws the FEN can't carry.
/// May be empty.
/// </param>
/// <returns>The selected move as a UCI string.</returns> /// <returns>The selected move as a UCI string.</returns>
Task<string> GetBestMoveAsync(string fen); Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens);
} }