diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index 29bf494..6e63375 100644 --- a/JoshHeaps.Net/Controllers/ChessController.cs +++ b/JoshHeaps.Net/Controllers/ChessController.cs @@ -1,6 +1,9 @@ -using JoshHeaps.Net.Models; +using JoshHeaps.Net.Hubs; +using JoshHeaps.Net.Models; +using JoshHeaps.Net.Services.Implementations; using JoshHeaps.Net.Services.Interfaces; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; using System.Collections.Concurrent; namespace JoshHeaps.Net.Controllers; @@ -11,7 +14,8 @@ public class ChessController( IChessService chessService, IBackgroundTaskQueue queue, IChessEngineFactory engineFactory, - IComputerMoveOrchestrator orchestrator) : ControllerBase + IComputerMoveOrchestrator orchestrator, + IHubContext chessHub) : ControllerBase { /// /// Store of ongoing games. @@ -23,6 +27,8 @@ public class ChessController( private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1); private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1); private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1); + private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30); /// /// Create a new chess game and store it in-memory. @@ -70,6 +76,31 @@ public class ChessController( }); } + /// + /// Create a game the computer plays against itself and auto-play it move by move, + /// broadcasting each move so it can be watched on the spectator page. + /// + [HttpGet("watch/cpu")] + [HttpGet("watch/cpu/{difficulty}")] + public ActionResult CreateSelfPlayGame(int difficulty = 4) + { + var gameState = chessService.CreateNewGame(); + _games[gameState.GameId] = gameState; + + gameState.IsVsComputer = true; + gameState.IsComputerVsComputer = true; + gameState.WhiteJoined = true; + gameState.BlackJoined = true; + gameState.WhitePlayerId = Guid.NewGuid(); + gameState.BlackPlayerId = Guid.NewGuid(); + gameState.Computer = engineFactory.Create(difficulty); + + ScheduleRemoveGame(gameState.GameId, _computerGameTimeout); + StartSelfPlay(gameState); + + return Ok(new { gameState.GameId }); + } + /// /// Joins the "pool" of chess players. /// Test code expects to receive a GUID for the player @@ -112,6 +143,30 @@ public class ChessController( }); } + /// + /// List in-progress games for spectators: both sides present and the game not yet decided. + /// + [HttpGet("active")] + public ActionResult GetActiveGames() + { + var activeGames = _games.Values + // In-progress games, plus finished computer-vs-computer games still in their result window. + .Where(g => g.WhiteJoined && g.BlackJoined + && ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer)) + .Select(g => new + { + g.GameId, + g.IsVsComputer, + g.IsComputerVsComputer, + CurrentPlayer = g.CurrentPlayer.ToString(), + MoveCount = g.MoveHistory.Count, + g.IsCheck + }) + .OrderByDescending(g => g.MoveCount); + + return Ok(activeGames); + } + /// /// Get the state of an existing game by ID. /// @@ -128,6 +183,7 @@ public class ChessController( gameState.IsCheck, gameState.IsCheckmate, gameState.IsStalemate, + gameState.IsThreefoldRepetition, EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null, gameState.WhiteCanCastleKingside, gameState.WhiteCanCastleQueenside, @@ -180,19 +236,63 @@ public class ChessController( if (!result.Success) return BadRequest(result); - if (result.IsCheckmate || result.IsStalemate) + var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition; + + if (isGameOver) ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout); else if (gameState.IsVsComputer) ScheduleRemoveGame(gameState.GameId, _computerGameTimeout); else ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout); - if (gameState.IsVsComputer && gameState.Computer is not null) + if (!isGameOver && gameState.IsVsComputer && gameState.Computer is not null) queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!)); return Ok(result); } + /// + /// Forfeit a game on behalf of the calling player, handing the win to the opponent. + /// Used when a player abandons a game (e.g. starts a new one mid-game). + /// + [HttpPost("forfeit")] + public async Task Forfeit([FromBody] ForfeitDto forfeit) + { + if (!_games.TryGetValue(forfeit.GameId, out var gameState)) + return NotFound("Game not found"); + + if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited) + return Ok(); + + var isWhitePlayer = gameState.WhitePlayerId == forfeit.PlayerId; + var isBlackPlayer = gameState.BlackPlayerId == forfeit.PlayerId; + + if (!isWhitePlayer && !isBlackPlayer) + return StatusCode(403, "You are not a player in this game."); + + gameState.IsForfeited = true; + gameState.Winner = isWhitePlayer ? PieceColor.Black : PieceColor.White; + + await chessHub.Clients.Group(gameState.GameId.ToString()) + .SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit"); + + ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout); + + return Ok(); + } + + /// + /// Export a game as PGN (works while the game is still in memory after it ends). + /// + [HttpGet("{gameId}/pgn")] + public ActionResult GetPgn(Guid gameId) + { + if (!_games.TryGetValue(gameId, out var gameState)) + return NotFound("Game not found"); + + return Content(gameState.ToPgn(), "application/x-chess-pgn"); + } + /// /// Get the legal moves for a specific piece in a specific game. /// @@ -226,6 +326,42 @@ public class ChessController( return Ok(allMoves); } + /// + /// Drives a computer-vs-computer game: keeps asking the engine for the side-to-move's + /// move (which applies and broadcasts it) until the game ends or is removed. + /// + private void StartSelfPlay(GameState gameState) + { + queue.Queue(async () => + { + // Give spectators a moment to join the SignalR group before the first move. + await Task.Delay(TimeSpan.FromSeconds(1)); + + while (_games.ContainsKey(gameState.GameId) + && !gameState.IsCheckmate + && !gameState.IsStalemate + && !gameState.IsThreefoldRepetition + && !gameState.IsForfeited) + { + try + { + await orchestrator.PlayAsync(gameState, gameState.Computer!); + } + catch (Exception ex) + { + Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}"); + break; + } + + await Task.Delay(_selfPlayMoveDelay); + } + + // Leave the finished game in place briefly so spectators can see the result. + if (_games.ContainsKey(gameState.GameId)) + ScheduleRemoveGame(gameState.GameId, _selfPlayResultTimeout); + }); + } + private static void ScheduleRemoveGame(Guid id, TimeSpan delay) { if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts)) diff --git a/JoshHeaps.Net/Models/ForfeitDto.cs b/JoshHeaps.Net/Models/ForfeitDto.cs new file mode 100644 index 0000000..6fde214 --- /dev/null +++ b/JoshHeaps.Net/Models/ForfeitDto.cs @@ -0,0 +1,7 @@ +namespace JoshHeaps.Net.Models; + +public class ForfeitDto +{ + public Guid GameId { get; set; } + public Guid PlayerId { get; set; } +} diff --git a/JoshHeaps.Net/Models/GameState.cs b/JoshHeaps.Net/Models/GameState.cs index 63579f9..c3c86da 100644 --- a/JoshHeaps.Net/Models/GameState.cs +++ b/JoshHeaps.Net/Models/GameState.cs @@ -28,10 +28,25 @@ public class GameState public bool IsCheck { get; set; } public bool IsCheckmate { get; set; } public bool IsStalemate { get; set; } + public bool IsThreefoldRepetition { get; set; } + public bool IsForfeited { get; set; } = false; + + // The color that won, when the game ended by forfeit (null while the game is live). + public PieceColor? Winner { get; set; } // Keep a history of moves if desired public List MoveHistory { get; set; } + // Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection. + public List PositionHistory { get; set; } + + // Moves in standard algebraic notation (SAN), in order, for PGN export. + public List SanHistory { 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; } @@ -42,6 +57,7 @@ public class GameState public Guid BlackPlayerId { get; set; } public bool IsVsComputer { get; set; } = false; + public bool IsComputerVsComputer { get; set; } = false; public IChessEngine? Computer { get; set; } @@ -54,5 +70,7 @@ public class GameState Board = new ChessPiece[8, 8]; Pieces = []; MoveHistory = []; + PositionHistory = []; + SanHistory = []; } } diff --git a/JoshHeaps.Net/Models/MoveResultDto.cs b/JoshHeaps.Net/Models/MoveResultDto.cs index 81850f2..86d33cb 100644 --- a/JoshHeaps.Net/Models/MoveResultDto.cs +++ b/JoshHeaps.Net/Models/MoveResultDto.cs @@ -7,4 +7,5 @@ public class MoveResultDto public bool IsCheck { get; set; } public bool IsCheckmate { get; set; } public bool IsStalemate { get; set; } + public bool IsThreefoldRepetition { get; set; } } diff --git a/JoshHeaps.Net/Pages/Chess.cshtml b/JoshHeaps.Net/Pages/Chess.cshtml index df65f87..314b112 100644 --- a/JoshHeaps.Net/Pages/Chess.cshtml +++ b/JoshHeaps.Net/Pages/Chess.cshtml @@ -24,6 +24,8 @@
+ + Watch other games →
diff --git a/JoshHeaps.Net/Pages/Index.cshtml b/JoshHeaps.Net/Pages/Index.cshtml index b71a3b2..be6aa12 100644 --- a/JoshHeaps.Net/Pages/Index.cshtml +++ b/JoshHeaps.Net/Pages/Index.cshtml @@ -61,6 +61,7 @@

Demos

+ diff --git a/JoshHeaps.Net/Pages/Watch.cshtml b/JoshHeaps.Net/Pages/Watch.cshtml new file mode 100644 index 0000000..80635e6 --- /dev/null +++ b/JoshHeaps.Net/Pages/Watch.cshtml @@ -0,0 +1,34 @@ +@page +@model JoshHeaps.Net.Pages.WatchModel +@{ + Layout = "_Layout"; + ViewData["Title"] = "Watch Chess"; +} + +
+

Live Chess

+

Loading games…

+
+ + + +
+ ← Play a game +
+ +
+ +@section Scripts { + + +} + +@section Styles { + + +} diff --git a/JoshHeaps.Net/Pages/Watch.cshtml.cs b/JoshHeaps.Net/Pages/Watch.cshtml.cs new file mode 100644 index 0000000..33484de --- /dev/null +++ b/JoshHeaps.Net/Pages/Watch.cshtml.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace JoshHeaps.Net.Pages +{ + public class WatchModel : PageModel + { + public void OnGet() + { + } + } +} diff --git a/JoshHeaps.Net/Resources/chess_engine.dll b/JoshHeaps.Net/Resources/chess_engine.dll index 5167dea..f7f7bfd 100644 Binary files a/JoshHeaps.Net/Resources/chess_engine.dll and b/JoshHeaps.Net/Resources/chess_engine.dll differ 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 adcaf82..3d89133 100644 --- a/JoshHeaps.Net/Services/Implementations/ChessService.cs +++ b/JoshHeaps.Net/Services/Implementations/ChessService.cs @@ -22,12 +22,14 @@ public class ChessService : IChessService gameState.Pieces.Clear(); gameState.MoveHistory.Clear(); + gameState.SanHistory.Clear(); gameState.WhiteCanCastleKingside = true; gameState.WhiteCanCastleQueenside = true; gameState.BlackCanCastleKingside = true; gameState.BlackCanCastleQueenside = true; gameState.EnPassantTarget = null; + gameState.HalfmoveClock = 0; SetupBlackPieces(gameState); SetupWhitePieces(gameState); @@ -35,6 +37,9 @@ public class ChessService : IChessService gameState.CurrentPlayer = PieceColor.White; UpdateCheckStatus(gameState); + + gameState.PositionHistory.Clear(); + gameState.PositionHistory.Add(PositionKey(gameState)); } private static void SetupBlackPieces(GameState gs) @@ -137,12 +142,23 @@ public class ChessService : IChessService if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol)) return new MoveResultDto { Success = false, Message = "Illegal move." }; + // SAN is built before the move (needs the pre-move board for captures/disambiguation); + // the check/mate suffix is appended after UpdateCheckStatus. + var sanBase = BuildSan(gameState, piece, targetPos, moveDto); + PerformMove(gameState, piece, targetPos, moveDto); UpdateCheckStatus(gameState); var notation = $"{piece.Id}:{piece.Position}->{targetPos}"; gameState.MoveHistory.Add(notation); + gameState.SanHistory.Add(sanBase + (gameState.IsCheckmate ? "#" : gameState.IsCheck ? "+" : "")); + + var positionKey = PositionKey(gameState); + gameState.PositionHistory.Add(positionKey); + + if (gameState.PositionHistory.Count(k => k == positionKey) >= 3) + gameState.IsThreefoldRepetition = true; return new MoveResultDto { @@ -150,14 +166,95 @@ public class ChessService : IChessService Message = "Move successful.", IsCheck = gameState.IsCheck, IsCheckmate = gameState.IsCheckmate, - IsStalemate = gameState.IsStalemate + IsStalemate = gameState.IsStalemate, + IsThreefoldRepetition = gameState.IsThreefoldRepetition }; } + /// + /// Build the standard-algebraic notation for a move from the position BEFORE it is + /// applied (the check/mate suffix is added by the caller afterward). + /// + private string BuildSan(GameState gs, ChessPiece piece, Position target, MoveDto moveDto) + { + var from = piece.Position; + + if (piece.Type == PieceType.King && Math.Abs(target.Col - from.Col) == 2) + return target.Col > from.Col ? "O-O" : "O-O-O"; + + bool targetOccupied = gs.Board[target.Row, target.Col] != null; + bool isEnPassant = piece.Type == PieceType.Pawn && from.Col != target.Col && !targetOccupied; + bool isCapture = targetOccupied || isEnPassant; + string dest = SquareName(target); + + if (piece.Type == PieceType.Pawn) + { + var san = isCapture ? $"{FileChar(from.Col)}x{dest}" : dest; + + bool promotes = (piece.Color == PieceColor.White && target.Row == 0) + || (piece.Color == PieceColor.Black && target.Row == 7); + + if (promotes) + san += "=" + PieceLetter(moveDto.PromotionChoice ?? PieceType.Queen); + + return san; + } + + return $"{PieceLetter(piece.Type)}{Disambiguation(gs, piece, target)}{(isCapture ? "x" : "")}{dest}"; + } + + /// + /// SAN disambiguation: when another piece of the same type and color can also reach the + /// target, qualify the origin by file, else rank, else both. + /// + private string Disambiguation(GameState gs, ChessPiece piece, Position target) + { + var rivals = gs.Pieces + .Where(p => p.Id != piece.Id && p.Type == piece.Type && p.Color == piece.Color && p.Position.Row >= 0) + .Where(p => GetLegalMovesForPiece(gs, p.Id).Any(m => m.Row == target.Row && m.Col == target.Col)) + .ToList(); + + if (rivals.Count == 0) + return ""; + + if (rivals.All(p => p.Position.Col != piece.Position.Col)) + return FileChar(piece.Position.Col).ToString(); + + if (rivals.All(p => p.Position.Row != piece.Position.Row)) + return (8 - piece.Position.Row).ToString(); + + return $"{FileChar(piece.Position.Col)}{8 - piece.Position.Row}"; + } + + private static char FileChar(int col) => (char)('a' + col); + + private static string SquareName(Position p) => $"{FileChar(p.Col)}{8 - p.Row}"; + + private static string PieceLetter(PieceType type) => type switch + { + PieceType.Knight => "N", + PieceType.Bishop => "B", + PieceType.Rook => "R", + PieceType.Queen => "Q", + PieceType.King => "K", + _ => "" + }; + + /// + /// The repetition signature of a position: the first four FEN fields — piece placement, + /// side to move, castling rights, and en-passant target. Move counters are excluded. + /// + private static string PositionKey(GameState gs) + { + var fields = gs.ToFen().Split(' '); + return string.Join(' ', fields.Take(4)); + } + private static void PerformMove(GameState gs, ChessPiece piece, Position targetPos, MoveDto moveDto) { 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); @@ -179,6 +276,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/PgnExporter.cs b/JoshHeaps.Net/Services/Implementations/PgnExporter.cs new file mode 100644 index 0000000..1765e23 --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/PgnExporter.cs @@ -0,0 +1,50 @@ +using JoshHeaps.Net.Models; +using System.Text; + +namespace JoshHeaps.Net.Services.Implementations; + +public static class PgnExporter +{ + /// + /// Render a game as PGN: the standard seven-tag header plus the SAN movetext and result. + /// + public static string ToPgn(this GameState gs) + { + var result = ResultTag(gs); + var name = gs.IsComputerVsComputer ? "Computer" : null; + + var sb = new StringBuilder(); + sb.AppendLine("[Event \"JoshHeaps.Net Chess\"]"); + sb.AppendLine("[Site \"joshheaps.net\"]"); + sb.AppendLine($"[Date \"{DateTime.Now:yyyy.MM.dd}\"]"); + sb.AppendLine($"[White \"{name ?? "White"}\"]"); + sb.AppendLine($"[Black \"{name ?? "Black"}\"]"); + sb.AppendLine($"[Result \"{result}\"]"); + sb.AppendLine(); + + for (int i = 0; i < gs.SanHistory.Count; i++) + { + if (i % 2 == 0) + sb.Append(i / 2 + 1).Append(". "); + + sb.Append(gs.SanHistory[i]).Append(' '); + } + + sb.Append(result); + return sb.ToString(); + } + + private static string ResultTag(GameState gs) + { + if (gs.IsCheckmate) + return gs.CurrentPlayer == PieceColor.White ? "0-1" : "1-0"; + + if (gs.IsStalemate || gs.IsThreefoldRepetition) + return "1/2-1/2"; + + if (gs.IsForfeited) + return gs.Winner == PieceColor.White ? "1-0" : "0-1"; + + return "*"; + } +} 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); } diff --git a/JoshHeaps.Net/wwwroot/css/chess/site.css b/JoshHeaps.Net/wwwroot/css/chess/site.css index c0bee9f..bdf63a9 100644 --- a/JoshHeaps.Net/wwwroot/css/chess/site.css +++ b/JoshHeaps.Net/wwwroot/css/chess/site.css @@ -28,6 +28,28 @@ margin: 2vh; } +#copyPgnBtn { + background-color: #8cd5ed; + color: #262626; + border-radius: 30px; + border: 0px; + cursor: pointer; + order: 1; + padding: 2vh; + margin: 2vh; +} + +#watchLink { + color: #8cd5ed; + text-decoration: none; + margin: 2vh; + order: 2; +} + +#watchLink:hover { + text-decoration: underline; +} + #chessContainer { display: flex; flex-direction: column; diff --git a/JoshHeaps.Net/wwwroot/css/chess/spectate.css b/JoshHeaps.Net/wwwroot/css/chess/spectate.css new file mode 100644 index 0000000..d47db37 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/css/chess/spectate.css @@ -0,0 +1,149 @@ +html, body { + background-color: #2b2c30; + color: #d6d6d6; + margin: 0; +} + +#watchHeader { + text-align: center; + padding: 2rem 1rem 0; +} + +#watchHeader h1 { + margin: 0; +} + +#watchStatus { + color: #9a9a9a; +} + +#watchControls { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin: 1rem 0; +} + +#cpuDifficulty { + background-color: #1e1e1e; + color: #d6d6d6; + border: 1px solid #444; + border-radius: 6px; + padding: 0.4rem; +} + +#startCpuVsCpu { + background-color: #8cd5ed; + color: #262626; + border: 0; + border-radius: 30px; + padding: 0.6rem 1.2rem; + cursor: pointer; +} + +#startCpuVsCpu:hover { + background-color: #a5e0f2; +} + +#backToPlay { + color: #8cd5ed; + text-decoration: none; +} + +#backToPlay:hover { + text-decoration: underline; +} + +#gamesFeed { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; + padding: 2rem; + box-sizing: border-box; +} + +.gameCard { + background-color: #1e1e1e; + border-radius: 10px; + padding: 1rem; + box-shadow: 0 0 12px rgba(0, 0, 0, 0.4); + position: relative; + cursor: pointer; +} + +.gameResult { + text-align: center; + font-weight: bold; + color: #8cd5ed; + margin-top: 0.75rem; +} + +.copyPgnBtn { + display: block; + margin: 0.75rem auto 0; + background-color: #8cd5ed; + color: #262626; + border: 0; + border-radius: 30px; + padding: 0.5rem 1rem; + cursor: pointer; +} + +.copyPgnBtn:hover { + background-color: #a5e0f2; +} + +.backButton { + display: none; +} + +body.fullscreen-open { + overflow: hidden; +} + +.gameCard.fullscreen { + position: fixed; + inset: 0; + z-index: 1000; + margin: 0; + border-radius: 0; + cursor: default; + background-color: #2b2c30; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.gameCard.fullscreen .miniBoard { + width: min(90vh, 90vw); + height: min(90vh, 90vw); +} + +.gameCard.fullscreen .backButton { + display: inline-block; + position: absolute; + top: 1rem; + left: 1rem; + background-color: #8cd5ed; + color: #262626; + border: 0; + border-radius: 30px; + padding: 0.6rem 1.2rem; + cursor: pointer; +} + +.gameCardHeader { + text-align: center; + font-weight: bold; + margin-bottom: 0.75rem; +} + +.miniBoard { + width: 100%; + aspect-ratio: 1 / 1; + display: grid; + grid-template-columns: repeat(8, 1fr); + grid-template-rows: repeat(8, 1fr); +} diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js index 19de069..e935454 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js @@ -14,6 +14,20 @@ const ChessAPI = { return await response.json(); }, + async getPgn(gameId) { + const response = await fetch(`/api/chess/${gameId}/pgn`); + if (!response.ok) throw new Error("PGN unavailable"); + return await response.text(); + }, + + async forfeit(gameId, playerId) { + await fetch("/api/chess/forfeit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ GameId: gameId, PlayerId: playerId }) + }); + }, + async getLegalMoves(pieceId) { const response = await fetch(`/api/chess/${GameState.currentGameId}/legalMoves/${pieceId}`); if (!response.ok) throw new Error("API failed"); @@ -101,9 +115,13 @@ const ChessAPI = { } else if (moveResult.isStalemate) { alert("🤝 Stalemate!"); gameOver = true; + } else if (moveResult.isThreefoldRepetition) { + alert("🤝 Draw by threefold repetition!"); + gameOver = true; } if (gameOver) { + showCopyPgn(GameState.currentGameId); await ChessSignalR.leaveGame(); } }, 500); diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js index c12e53a..de0ef5b 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js @@ -15,6 +15,10 @@ const ChessSignalR = { await this.handleMoveUpdate(gameId, moveDto, moveResultDto); }); + this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => { + await this.handleGameOver(gameId, winner, reason); + }); + try { await this.connection.start(); console.log("✅ SignalR connected"); @@ -34,6 +38,18 @@ const ChessSignalR = { ChessAPI.alertGameStatusChange(moveResultDto); }, + async handleGameOver(gameId, winner, reason) { + if (gameId !== GameState.currentGameId) return; + + const youWon = (winner === "White") === GameState.currentPlayerIsWhite; + + if (reason === "forfeit") + alert(youWon ? "🏳️ Your opponent forfeited — you win!" : "🏳️ You forfeited this game."); + + showCopyPgn(gameId); + await this.leaveGame(); + }, + async notifyMoveMade(moveDto, moveResult) { if (this.connection) { await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult); diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js new file mode 100644 index 0000000..96debc7 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js @@ -0,0 +1,287 @@ +const Spectate = { + connection: null, + games: new Map(), // gameId -> { isVsComputer, isComputerVsComputer, result } + pgns: new Map(), // gameId -> PGN text (prefetched when a game finishes) + + pieceTypeNames: ["Pawn", "Rook", "Knight", "Bishop", "Queen", "King"], + + async init() { + this.connection = new signalR.HubConnectionBuilder() + .withUrl("/chessHub") + .configureLogging(signalR.LogLevel.Warning) + .build(); + + this.connection.on("ReceiveMoveUpdate", (gameId, moveDto) => + this.handleMoveUpdate(gameId, moveDto)); + + this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId)); + + try { + await this.connection.start(); + } catch (err) { + console.error("❌ SignalR failed to start:", err); + } + + await this.refreshGames(); + setInterval(() => this.refreshGames(), 5000); + }, + + async startCpuGame() { + const difficulty = document.getElementById("cpuDifficulty").value; + + try { + await fetch(`/api/chess/watch/cpu/${difficulty}`); + await this.refreshGames(); + } catch (err) { + console.error("❌ Could not start CPU vs CPU game.", err); + } + }, + + async refreshGames() { + let games; + + try { + const response = await fetch("/api/chess/active"); + games = await response.json(); + } catch { + return; + } + + const activeIds = new Set(games.map(g => g.gameId)); + + for (const gameId of [...this.games.keys()]) + if (!activeIds.has(gameId)) + await this.removeGame(gameId); + + for (const game of games) + if (this.games.has(game.gameId)) + this.updateHeader(game.gameId, game.currentPlayer, game.moveCount, game.isCheck); + else + await this.addGame(game); + + const status = document.getElementById("watchStatus"); + status.textContent = games.length === 0 + ? "No games are being played right now." + : `${games.length} game${games.length === 1 ? "" : "s"} in progress`; + }, + + async addGame(game) { + this.games.set(game.gameId, { + isVsComputer: game.isVsComputer, + isComputerVsComputer: game.isComputerVsComputer + }); + + const card = document.createElement("div"); + card.className = "gameCard"; + card.id = `card-${game.gameId}`; + card.onclick = () => this.enterFullscreen(game.gameId); + + const back = document.createElement("button"); + back.className = "backButton"; + back.textContent = "← Back"; + back.onclick = (event) => this.exitFullscreen(game.gameId, event); + card.appendChild(back); + + const header = document.createElement("div"); + header.className = "gameCardHeader"; + header.id = `header-${game.gameId}`; + header.textContent = this.headerText(this.games.get(game.gameId), game.currentPlayer, game.moveCount, game.isCheck); + card.appendChild(header); + + const board = document.createElement("div"); + board.className = "miniBoard"; + + for (let i = 0; i < 64; i++) { + const square = document.createElement("div"); + square.id = `sq-${game.gameId}-${i}`; + square.className = `chessSquare ${(i + Math.floor(i / 8)) % 2 === 0 ? "light" : "dark"}`; + board.appendChild(square); + } + + card.appendChild(board); + document.getElementById("gamesFeed").appendChild(card); + + await this.connection.invoke("JoinWebsocketGroup", game.gameId).catch(() => { }); + await this.renderGame(game.gameId); + }, + + async removeGame(gameId) { + this.games.delete(gameId); + this.pgns.delete(gameId); + document.getElementById(`card-${gameId}`)?.remove(); + await this.connection.invoke("LeaveWebsocketGroup", gameId).catch(() => { }); + }, + + async renderGame(gameId) { + const response = await fetch(`/api/chess/${gameId}`); + + if (!response.ok) return; + + const state = await response.json(); + const result = this.resultTextFromState(state); + const stored = this.games.get(gameId); + + if (stored) stored.result = result; + + this.renderPieces(gameId, state.pieces); + this.updateHeader(gameId, state.currentPlayer, state.moveHistory.length, state.isCheck); + this.setResult(gameId, result); + }, + + async handleMoveUpdate(gameId, moveDto) { + if (!this.games.has(gameId)) return; + + await this.renderGame(gameId); + this.highlightMove(gameId, moveDto); + }, + + renderPieces(gameId, pieces) { + this.clearBoard(gameId); + + pieces.forEach(piece => { + const square = document.getElementById(`sq-${gameId}-${piece.row * 8 + piece.col}`); + + if (!square) return; + + const img = document.createElement("img"); + img.src = this.pieceImageUrl(piece); + img.alt = piece.type; + img.className = "chessPiece"; + img.draggable = false; + square.appendChild(img); + }); + }, + + clearBoard(gameId) { + for (let i = 0; i < 64; i++) { + const square = document.getElementById(`sq-${gameId}-${i}`); + + if (!square) continue; + + square.innerHTML = ""; + square.classList.remove("previous-start", "previous-end"); + } + }, + + highlightMove(gameId, moveDto) { + document.getElementById(`sq-${gameId}-${moveDto.sourceRow * 8 + moveDto.sourceCol}`)?.classList.add("previous-start"); + document.getElementById(`sq-${gameId}-${moveDto.targetRow * 8 + moveDto.targetCol}`)?.classList.add("previous-end"); + }, + + updateHeader(gameId, currentPlayer, moveCount, isCheck) { + const stored = this.games.get(gameId); + const header = document.getElementById(`header-${gameId}`); + + if (stored && header) + header.textContent = this.headerText(stored, currentPlayer, moveCount, isCheck); + }, + + gameLabel(stored) { + if (stored.isComputerVsComputer) return "CPU vs CPU"; + if (stored.isVsComputer) return "Vs CPU"; + return "Player vs Player"; + }, + + headerText(stored, currentPlayer, moveCount, isCheck) { + if (stored.result) + return `${this.gameLabel(stored)} · move ${moveCount} · final`; + + const check = isCheck ? " • check" : ""; + return `${this.gameLabel(stored)} · move ${moveCount} · ${currentPlayer} to move${check}`; + }, + + resultTextFromState(state) { + if (state.isCheckmate) + return `${state.currentPlayer === "White" ? "Black" : "White"} wins by checkmate`; + if (state.isStalemate) + return "Draw — stalemate"; + if (state.isThreefoldRepetition) + return "Draw — threefold repetition"; + return null; + }, + + setResult(gameId, text) { + const card = document.getElementById(`card-${gameId}`); + + if (!card) return; + + let banner = card.querySelector(".gameResult"); + + if (!text) { + banner?.remove(); + card.querySelector(".copyPgnBtn")?.remove(); + card.classList.remove("over"); + return; + } + + if (!banner) { + banner = document.createElement("div"); + banner.className = "gameResult"; + card.appendChild(banner); + } + + banner.textContent = text; + card.classList.add("over"); + this.addCopyPgn(gameId, card); + }, + + addCopyPgn(gameId, card) { + if (card.querySelector(".copyPgnBtn")) return; + + const btn = document.createElement("button"); + btn.className = "copyPgnBtn"; + btn.textContent = "Copy PGN"; + btn.onclick = (event) => { event.stopPropagation(); this.copyPgn(gameId); }; + card.appendChild(btn); + + // Prefetch now (while the game is still in memory) so copy works during the + // brief window before the finished game is cleaned up. + fetch(`/api/chess/${gameId}/pgn`) + .then(r => r.ok ? r.text() : null) + .then(t => { if (t) this.pgns.set(gameId, t); }) + .catch(() => { }); + }, + + async copyPgn(gameId) { + let pgn = this.pgns.get(gameId); + + if (!pgn) { + try { + const r = await fetch(`/api/chess/${gameId}/pgn`); + if (r.ok) pgn = await r.text(); + } catch { /* ignore */ } + } + + if (!pgn) { + alert("PGN is no longer available for this game."); + return; + } + + try { + await navigator.clipboard.writeText(pgn); + alert("📋 PGN copied to clipboard!"); + } catch { + alert("Couldn't access the clipboard. Here's the PGN:\n\n" + pgn); + } + }, + + enterFullscreen(gameId) { + document.getElementById(`card-${gameId}`)?.classList.add("fullscreen"); + document.body.classList.add("fullscreen-open"); + }, + + exitFullscreen(gameId, event) { + event?.stopPropagation(); + document.getElementById(`card-${gameId}`)?.classList.remove("fullscreen"); + document.body.classList.remove("fullscreen-open"); + }, + + pieceImageUrl(piece) { + const color = piece.color === 0 ? "White" : "Black"; + return `/images/Chess Images/${color}${this.pieceTypeNames[piece.type]}.svg`; + } +}; + +window.addEventListener("load", () => Spectate.init()); + +console.log("Spectate.js loaded"); diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js index abae265..6cdac8b 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js @@ -1,5 +1,52 @@ +let lastPgn = null; + +async function showCopyPgn(gameId) { + try { + lastPgn = await ChessAPI.getPgn(gameId); + const btn = document.getElementById("copyPgnBtn"); + if (btn) btn.style.display = ""; + } catch (err) { + console.warn("Could not load PGN.", err); + } +} + +async function copyPgn() { + if (!lastPgn) return; + + try { + await navigator.clipboard.writeText(lastPgn); + alert("📋 PGN copied to clipboard!"); + } catch { + alert("Couldn't access the clipboard. Here's the PGN:\n\n" + lastPgn); + } +} + +function resetCopyPgn() { + lastPgn = null; + const btn = document.getElementById("copyPgnBtn"); + if (btn) btn.style.display = "none"; +} + +window.copyPgn = copyPgn; +window.showCopyPgn = showCopyPgn; + +async function forfeitCurrentGame() { + const gameId = ChessUtils.getCookie("chessGameId"); + const playerId = ChessUtils.getCookie("chessPlayerId"); + + if (!gameId || !playerId) return; + + try { + await ChessAPI.forfeit(gameId, playerId); + } catch (err) { + console.warn("Could not forfeit previous game.", err); + } +} + async function startNewGame() { await ChessSignalR.stopConnection(); + await forfeitCurrentGame(); + resetCopyPgn(); try { const gameData = await ChessAPI.joinGame(); @@ -25,6 +72,8 @@ async function startNewGame() { async function startCPUGame() { await ChessSignalR.stopConnection(); + await forfeitCurrentGame(); + resetCopyPgn(); try { const difficulty = await ChessModals.promptDifficulty(); diff --git a/native/chess_engine/include/chess_engine.h b/native/chess_engine/include/chess_engine.h index 0d494c6..9caca72 100644 --- a/native/chess_engine/include/chess_engine.h +++ b/native/chess_engine/include/chess_engine.h @@ -57,6 +57,10 @@ CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine, /* Compute the best move for the given position. * engine : handle from engine_create. * fen : null-terminated UTF-8 FEN of the position to move from. + * history : optional null-terminated UTF-8 list of the prior positions since the + * last irreversible move (capture/pawn move), one FEN per line, oldest + * first, NOT including `fen`. Lets the engine detect threefold/50-move + * draws that the FEN alone can't carry. May be NULL or empty. * out_buf : host-owned buffer the engine writes the UCI move into, * as a null-terminated ASCII string (e.g. "e2e4\0"). * out_len : capacity of out_buf in bytes (host passes >= 8). @@ -64,6 +68,7 @@ CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine, * MUST NOT write more than out_len bytes including the NUL terminator. */ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine, const char* fen, + const char* history, char* out_buf, int out_len); diff --git a/native/chess_engine/src/chess_engine.cpp b/native/chess_engine/src/chess_engine.cpp index 242f52a..6a113f9 100644 --- a/native/chess_engine/src/chess_engine.cpp +++ b/native/chess_engine/src/chess_engine.cpp @@ -18,15 +18,66 @@ #include "movegen.h" #include "uci.h" +#include +#include +#include +#include #include #include -#include +#include #include #include #include +#include -/* Internal engine state. Put your search tables, transposition table, etc. here. */ +/* Search score constants. Scores are side-to-move-relative (negamax): positive is + * good for whoever is to move. MATE_BOUND is the threshold above which a score is a + * "mate in N" rather than a positional eval; INF is the window sentinel (kept above + * MATE so negating it can never hit signed-overflow UB the way INT_MIN would). */ +static constexpr int MATE = 200000; +static constexpr int MATE_BOUND = MATE - 1000; +static constexpr int INF = 1000000; + +/* Bound kind stored in a TT entry. LOWER = a fail-high (true score >= stored), + * UPPER = a fail-low (true score <= stored), EXACT = fully resolved. */ +enum class Bound : uint8_t { NONE, EXACT, LOWER, UPPER }; + +/* One shared, process-wide transposition table backs every game (every engine + * handle), so analysis persists and is reused across games. It is lock-free: each + * slot is two 64-bit words — `data` (the packed payload) and `xorKey` (the Zobrist + * key XOR-ed with `data`). A reader recovers the key as `xorKey ^ data`; if two + * concurrent searches tore the pair, the recovered key won't match and the read is + * treated as a miss — never a wrong-but-trusted entry (Hyatt's lockless hashing). */ +struct TTEntry { + std::atomic xorKey{0}; + std::atomic data{0}; +}; + +struct TranspositionTable { + std::unique_ptr entries; + size_t mask = 0; /* count - 1; count is a power of two */ +}; + +static TranspositionTable g_tt; +static constexpr size_t TT_MEGABYTES = 256; + +/* Pack/unpack the 64-bit payload: score(32) | move(16) | depth(8) | bound(8). A stored + * entry always has depth >= 1 and a non-NONE bound, so a real entry never packs to 0 — + * letting data == 0 mean "empty slot". */ +static uint64_t tt_pack(int score, chess::Move move, int depth, Bound bound) { + return static_cast(static_cast(score)) + | (static_cast(move.data) << 32) + | (static_cast(static_cast(depth)) << 48) + | (static_cast(static_cast(bound)) << 56); +} +static int tt_score(uint64_t d) { return static_cast(static_cast(d & 0xFFFFFFFFu)); } +static chess::Move tt_move (uint64_t d) { return chess::Move(static_cast(d >> 32)); } +static int tt_depth(uint64_t d) { return static_cast(static_cast(d >> 48)); } +static Bound tt_bound(uint64_t d) { return static_cast(static_cast(d >> 56)); } + +/* Internal engine state. One ChessEngine = one game. The table is NOT here: it is the + * shared g_tt above. */ struct ChessEngine { int skill = 20; /* 1..20 from the UI; controls search depth */ }; @@ -57,10 +108,29 @@ static int parse_skill(const char* options, int fallback) { return v < 1 ? 1 : v > 20 ? 20 : v; } -/* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no move - * ordering or quiescence yet, so deep fixed-depth runs get expensive quickly. */ +/* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no + * quiescence yet, so deep fixed-depth runs get expensive quickly. */ static int depth_for_skill(int skill) { - return skill; /* skill 1 -> 2 plies ... skill 20 -> 7 plies */ + return skill; /* skill N -> N plies */ +} + +static size_t floor_pow2(size_t n) { + size_t p = 1; + while ((p << 1) != 0 && (p << 1) <= n) p <<= 1; + return p; +} + +/* Allocate the shared table exactly once, to the largest power-of-two entry count that + * fits in TT_MEGABYTES. Power-of-two count lets indexing use `key & mask`. Thread-safe: + * call_once guards the first concurrent engine_create. Entries start zeroed (empty). */ +static void ensure_tt() { + static std::once_flag once; + std::call_once(once, [] { + size_t count = floor_pow2((TT_MEGABYTES << 20) / sizeof(TTEntry)); + if (count < 1) count = 1; + g_tt.entries = std::make_unique(count); + g_tt.mask = count - 1; + }); } /* Positional multiplier in [0.5, 2.0] based on a square's distance from the four @@ -178,10 +248,75 @@ static int evaluate(const chess::Position& pos) { return score; } +/* evaluate() is white-positive (absolute). Negamax needs it relative to the side to + * move, so flip the sign when black is to move. */ +static int evaluate_stm(const chess::Position& pos, bool whiteToMove) { + int s = evaluate(pos); + return whiteToMove ? s : -s; +} + +/* Mate scores are "mate in N from THIS node", so they must be re-anchored to the + * probing node's ply when crossing the TT (store adds ply, retrieve subtracts it). + * Non-mate scores pass through untouched. */ +static int score_to_tt(int s, int ply) { return s >= MATE_BOUND ? s + ply : s <= -MATE_BOUND ? s - ply : s; } +static int score_from_tt(int s, int ply) { return s >= MATE_BOUND ? s - ply : s <= -MATE_BOUND ? s + ply : s; } + +static int piece_value(chess::PieceType pt) { + switch (pt) { + case chess::PAWN: return 100; + case chess::KNIGHT: return 320; + case chess::BISHOP: return 330; + case chess::ROOK: return 500; + case chess::QUEEN: return 900; + default: return 0; + } +} + +/* Heuristic for searching the most promising moves first, which makes alpha-beta + * prune far more. The TT's best move (if any) goes first, then checks, then captures + * by MVV-LVA (grab the most valuable victim with the least valuable attacker). + * `scoreChecks` gates the expensive gives_check term to near-leaf nodes. */ +static int order_score(chess::Position& pos, chess::Move m, chess::Move ttMove, bool scoreChecks) { + if (m == ttMove) + return 2000000; /* dwarfs any capture/check score below */ + + int score = 0; + + if (scoreChecks && pos.gives_check(m)) + score += 1000; + + chess::Piece victim = pos.piece_on(m.to()); + if (victim != chess::NO_PIECE) + score += 100 + 10 * piece_value(chess::type_of(victim)) + - piece_value(chess::type_of(pos.piece_on(m.from()))); + else if (m.type() == chess::EN_PASSANT) + score += 100 + 10 * piece_value(chess::PAWN); + + return score; +} + +/* Sort the move list in place, best-scoring first. Scores are computed once up + * front so gives_check isn't re-evaluated on every comparison. ttMove may be + * MOVE_NONE, in which case no move matches it and ordering falls back to captures. */ +static void order_moves(chess::Position& pos, chess::MoveList& moves, chess::Move ttMove, bool scoreChecks) { + struct ScoredMove { int score; chess::Move move; }; + ScoredMove scored[256]; + + for (int i = 0; i < moves.size(); i++) + scored[i] = { order_score(pos, moves.moves[i], ttMove, scoreChecks), moves.moves[i] }; + + std::sort(scored, scored + moves.size(), + [](const ScoredMove& a, const ScoredMove& b) { return a.score > b.score; }); + + for (int i = 0; i < moves.size(); i++) + moves.moves[i] = scored[i].move; +} + extern "C" { CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) { ensure_initialized(); + ensure_tt(); auto* e = new (std::nothrow) ChessEngine(); if (!e) return nullptr; e->skill = parse_skill(options, e->skill); @@ -195,45 +330,93 @@ CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine, return CHESS_OK; /* TODO: store options */ } -static int alpha_beta(chess::Position& pos, int depth, int maxDepth, int bestForWhite, int bestForBlack, bool whiteToMove) { - if (depth == maxDepth) - return evaluate(pos); +/* Negamax alpha-beta over the shared transposition table. `maxDepth` is the searching + * bot's difficulty (its root depth); `depth` is remaining depth (draft); `ply` is + * distance from the root (mate scoring only). Scores are side-to-move-relative. + * Fail-soft: returns the true best found even outside [alpha, beta]. */ +static int negamax(chess::Position& pos, int maxDepth, int depth, int ply, + int alpha, int beta, bool whiteToMove, uint64_t& nodes) { + nodes++; - chess::MoveList moves; + /* A draw is 0 even at the search horizon, and the TT key doesn't encode repetition + * history, so this must come before both the leaf eval and any TT probe. */ + if (ply > 0 && pos.is_draw()) + return 0; + + if (depth <= 0) + return evaluate_stm(pos, whiteToMove); + + const uint64_t key = pos.key(); + TTEntry& slot = g_tt.entries[key & g_tt.mask]; + const uint64_t data = slot.data.load(std::memory_order_relaxed); + const uint64_t xkey = slot.xorKey.load(std::memory_order_relaxed); + + chess::Move ttMove = chess::MOVE_NONE; + + if (data != 0 && (xkey ^ data) == key) { /* lockless: XOR check rejects torn reads */ + ttMove = tt_move(data); /* always reusable for ordering */ + int edepth = tt_depth(data); + Bound b = tt_bound(data); + + /* Trust the score only if it was searched deep enough for this node AND no deeper + * than this bot's own strength — so a weak bot can't borrow a stronger game's + * deeper analysis (it still gets the move for ordering, which can't leak strength). */ + if (edepth >= depth && edepth <= maxDepth) { + int s = score_from_tt(tt_score(data), ply); + if (b == Bound::EXACT) return s; + if (b == Bound::LOWER && s >= beta) return s; + if (b == Bound::UPPER && s <= alpha) return s; + } + } + + chess::MoveList moves; pos.generate_legal(moves); if (moves.size() == 0) - return pos.is_draw() ? 0 : whiteToMove ? -200000 + depth : 200000 - depth; + return pos.is_draw() ? 0 : -MATE + ply; /* checkmate against side to move */ + + order_moves(pos, moves, ttMove, depth <= 2); + + const int alphaOrig = alpha; + int best = -INF; + chess::Move bestMove = chess::MOVE_NONE; for (int i = 0; i < moves.size(); i++) { - chess::Move move = moves.moves[i]; - pos.do_move(move); - int moveScore = alpha_beta(pos, depth + 1, maxDepth, bestForWhite, bestForBlack, !whiteToMove); - if (whiteToMove) { - if (moveScore >= bestForBlack) { - pos.undo_move(move); - return bestForBlack; - } - if (moveScore > bestForWhite) - bestForWhite = moveScore; - } - else { - if (moveScore <= bestForWhite) { - pos.undo_move(move); - return bestForWhite; - } - if (moveScore < bestForBlack) - bestForBlack = moveScore; - } - + chess::Move move = moves.moves[i]; + pos.do_move(move); + int score = -negamax(pos, maxDepth, depth - 1, ply + 1, -beta, -alpha, !whiteToMove, nodes); pos.undo_move(move); + + if (score > best) { + best = score; + bestMove = move; + } + if (best > alpha) + alpha = best; + if (best >= beta) + break; /* fail-high cutoff */ } - return whiteToMove ? bestForWhite : bestForBlack; + Bound flag = best <= alphaOrig ? Bound::UPPER + : best >= beta ? Bound::LOWER + : Bound::EXACT; + + /* Depth-preferred replacement: keep the deepest analysis of each slot. The stored + * payload is written before the xorKey so any concurrent reader that catches a + * half-update fails the XOR check and treats it as a miss. */ + int storedDepth = (data == 0) ? -1 : tt_depth(data); + if (depth >= storedDepth) { + uint64_t packed = tt_pack(score_to_tt(best, ply), bestMove, depth, flag); + slot.data.store(packed, std::memory_order_relaxed); + slot.xorKey.store(key ^ packed, std::memory_order_relaxed); + } + + return best; } CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine, const char* fen, + const char* history, char* out_buf, int out_len) { if (!engine) return CHESS_ERR_NULL_HANDLE; @@ -242,31 +425,62 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine, auto held = std::make_unique(chess::Position::from_fen(fen)); chess::Position& pos = *held; bool whiteToMove = pos.side_to_move() == chess::WHITE; + + /* Seed the prior positions (one FEN per line) so is_draw() sees repetitions and + * the 50-move count that the current FEN alone can't express. */ + if (history && *history) { + std::vector priorKeys; + const char* p = history; + while (*p) { + const char* nl = std::strchr(p, '\n'); + size_t len = nl ? static_cast(nl - p) : std::strlen(p); + if (len > 0) + priorKeys.push_back(chess::Position::from_fen(std::string(p, len)).key()); + if (!nl) break; + p = nl + 1; + } + if (!priorKeys.empty()) + pos.seed_history(priorKeys.data(), static_cast(priorKeys.size())); + } + chess::MoveList moves; pos.generate_legal(moves); if (moves.size() == 0) return CHESS_ERR_NO_MOVE; + uint64_t nodes = 0; int maxDepth = depth_for_skill(engine->skill); + chess::Move bestMove = moves.moves[0]; /* guaranteed-legal fallback */ - int bestForWhite = std::numeric_limits::min(); - int bestForBlack = std::numeric_limits::max(); - chess::Move bestMove = moves.moves[0]; + /* Iterative deepening: each depth seeds the next depth's move ordering (via the + * previous best move and the TT it filled), which makes the deeper search prune + * far harder than searching to maxDepth cold. */ + for (int d = 1; d <= maxDepth; d++) { + int alpha = -INF, beta = INF; + chess::Move iterBest = bestMove; + int iterScore = -INF; - for (int i = 0; i < moves.size(); i++) { - chess::Move move = moves.moves[i]; - pos.do_move(move); - int score = alpha_beta(pos, 1, maxDepth, bestForWhite, bestForBlack, !whiteToMove); - pos.undo_move(move); + order_moves(pos, moves, iterBest, true); - if (whiteToMove && score > bestForWhite) { - bestForWhite = score; - bestMove = move; - } - else if (!whiteToMove && score < bestForBlack) { - bestForBlack = score; - bestMove = move; + for (int i = 0; i < moves.size(); i++) { + chess::Move move = moves.moves[i]; + pos.do_move(move); + int score = -negamax(pos, maxDepth, d - 1, 1, -beta, -alpha, !whiteToMove, nodes); + pos.undo_move(move); + + if (score > iterScore) { + iterScore = score; + iterBest = move; + } + if (score > alpha) + alpha = score; } + + bestMove = iterBest; /* commit only a fully completed iteration */ + + std::fprintf(stderr, "depth %d nodes %llu best %s score %d\n", + d, static_cast(nodes), + chess::move_to_uci(iterBest).c_str(), iterScore); } return copy_out(chess::move_to_uci(bestMove).c_str(), out_buf, out_len); diff --git a/native/chess_engine/src/position.cpp b/native/chess_engine/src/position.cpp index 3330b05..065ea19 100644 --- a/native/chess_engine/src/position.cpp +++ b/native/chess_engine/src/position.cpp @@ -316,6 +316,18 @@ bool Position::insufficient_material() const { return minors <= 1; // KvK, KvKN, KvKB } +void Position::seed_history(const uint64_t* priorKeys, int count) { + if (count <= 0) return; + if (count > 1000) count = 1000; // leave headroom in repKeys for search plies + + uint64_t current = zkey; // from_fen placed this at repKeys[0] + for (int i = 0; i < count; ++i) + repKeys[i] = priorKeys[i]; + repKeys[count] = current; + repCount = count + 1; + rule50 = count; // == half-moves since the last irreversible move +} + bool Position::is_draw() const { if (rule50 >= 100) return true; if (insufficient_material()) return true; diff --git a/native/chess_engine/src/position.h b/native/chess_engine/src/position.h index cc9e99e..8976394 100644 --- a/native/chess_engine/src/position.h +++ b/native/chess_engine/src/position.h @@ -47,6 +47,11 @@ public: void do_move(Move m); void undo_move(Move m); + // Seed prior-position keys (oldest first, excluding the current position) so + // is_draw() can see game history the FEN doesn't carry. Call once, right after + // from_fen and before any do_move. + void seed_history(const uint64_t* priorKeys, int count); + // --- freebies --- uint64_t key() const { return zkey; } bool is_draw() const; // 50-move + threefold + insufficient material