From d87cea572fe1340ebf24f56f739ead42643b2450 Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Mon, 8 Jun 2026 18:59:10 -0600 Subject: [PATCH] Feed move history and halfmove clock to the engine for repetition detection - Track HalfmoveClock in GameState/ChessService (reset on captures and pawn moves) - Emit the real halfmove clock in ToFen and add GameState.RepetitionHistory() (prior positions since the last irreversible move) in ChessEngineHelpers - Pass that history through IChessEngine.GetBestMoveAsync to the native engine; Stockfish ignores it Co-Authored-By: Claude Opus 4.8 (1M context) --- JoshHeaps.Net/Models/GameState.cs | 4 +++ .../Implementations/ChessEngineHelpers.cs | 25 ++++++++++++++++++- .../Services/Implementations/ChessService.cs | 5 ++++ .../ComputerMoveOrchestrator.cs | 2 +- .../Implementations/CustomChessEngine.cs | 9 ++++--- .../Services/Implementations/Stockfish.cs | 3 ++- .../Services/Interfaces/IChessEngine.cs | 7 +++++- 7 files changed, 47 insertions(+), 8 deletions(-) diff --git a/JoshHeaps.Net/Models/GameState.cs b/JoshHeaps.Net/Models/GameState.cs index 5781639..297dc62 100644 --- a/JoshHeaps.Net/Models/GameState.cs +++ b/JoshHeaps.Net/Models/GameState.cs @@ -40,6 +40,10 @@ public class GameState // Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection. public List 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). // Alternatively, you can iterate the Board array. public List Pieces { get; set; } diff --git a/JoshHeaps.Net/Services/Implementations/ChessEngineHelpers.cs b/JoshHeaps.Net/Services/Implementations/ChessEngineHelpers.cs index 0845864..ae3f860 100644 --- a/JoshHeaps.Net/Services/Implementations/ChessEngineHelpers.cs +++ b/JoshHeaps.Net/Services/Implementations/ChessEngineHelpers.cs @@ -85,11 +85,34 @@ public static class ChessEngineHelpers /* 5-6) half-move clock + full-move number */ int fullMoves = gs.MoveHistory.Count / 2 + 1; - sb.Append(" 0 ").Append(fullMoves); + sb.Append(' ').Append(gs.HalfmoveClock).Append(' ').Append(fullMoves); return sb.ToString(); } + /// + /// 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. + /// + public static IReadOnlyList 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(); + + var fens = new List(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 ---------- */ private static string GetCastlingFlags(GameState gs) diff --git a/JoshHeaps.Net/Services/Implementations/ChessService.cs b/JoshHeaps.Net/Services/Implementations/ChessService.cs index 613eb59..e99884d 100644 --- a/JoshHeaps.Net/Services/Implementations/ChessService.cs +++ b/JoshHeaps.Net/Services/Implementations/ChessService.cs @@ -28,6 +28,7 @@ public class ChessService : IChessService gameState.BlackCanCastleKingside = true; gameState.BlackCanCastleQueenside = true; gameState.EnPassantTarget = null; + gameState.HalfmoveClock = 0; SetupBlackPieces(gameState); SetupWhitePieces(gameState); @@ -178,6 +179,7 @@ public class ChessService : IChessService { var oldPos = piece.Position; 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); @@ -199,6 +201,9 @@ public class ChessService : IChessService 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 ? PieceColor.Black : PieceColor.White; diff --git a/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs b/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs index 45d24df..21c15b1 100644 --- a/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs +++ b/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs @@ -15,7 +15,7 @@ public sealed class ComputerMoveOrchestrator( { 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( state, diff --git a/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs b/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs index 64e94a7..5675a69 100644 --- a/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs +++ b/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs @@ -28,9 +28,10 @@ public sealed partial class CustomChessEngine : IChessEngine _handle = new EngineSafeHandle(handle); } - public Task GetBestMoveAsync(string fen) => Task.Run(() => GetBestMove(fen)); + public Task GetBestMoveAsync(string fen, IReadOnlyList 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 byte* buffer = stackalloc byte[bufferLength]; @@ -40,7 +41,7 @@ public sealed partial class CustomChessEngine : IChessEngine try { _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) 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)] [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)] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] diff --git a/JoshHeaps.Net/Services/Implementations/Stockfish.cs b/JoshHeaps.Net/Services/Implementations/Stockfish.cs index 82e9b43..bf2d3d9 100644 --- a/JoshHeaps.Net/Services/Implementations/Stockfish.cs +++ b/JoshHeaps.Net/Services/Implementations/Stockfish.cs @@ -74,8 +74,9 @@ public sealed class Stockfish : IChessEngine WaitFor("readyok").GetAwaiter().GetResult(); } - public async Task GetBestMoveAsync(string fen) + public async Task GetBestMoveAsync(string fen, IReadOnlyList historyFens) { + // historyFens is unused: Stockfish tracks repetition from the position it's given. Send($"position fen {fen}"); Send($"go depth {_skill}"); string? best = null; diff --git a/JoshHeaps.Net/Services/Interfaces/IChessEngine.cs b/JoshHeaps.Net/Services/Interfaces/IChessEngine.cs index 4dc5d69..9f7f08e 100644 --- a/JoshHeaps.Net/Services/Interfaces/IChessEngine.cs +++ b/JoshHeaps.Net/Services/Interfaces/IChessEngine.cs @@ -14,6 +14,11 @@ public interface IChessEngine : IAsyncDisposable /// Returns the engine's chosen move in UCI long-algebraic form (e.g. "e2e4", "e7e8q"). /// /// The current position as a FEN string. + /// + /// 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. + /// /// The selected move as a UCI string. - Task GetBestMoveAsync(string fen); + Task GetBestMoveAsync(string fen, IReadOnlyList historyFens); }