From c3280aa6d38903407a596513c0986bdaed18e372 Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Mon, 8 Jun 2026 13:58:22 -0600 Subject: [PATCH 1/2] add custom chess engine --- .github/workflows/deploy.yml | 4 +- .github/workflows/dotnet.yml | 4 +- .github/workflows/playwright-tests.yml | 4 +- .gitignore | 5 +- JoshHeaps.Net.sln | 38 +- JoshHeaps.Net.slnf | 9 + JoshHeaps.Net/Controllers/ChessController.cs | 15 +- JoshHeaps.Net/JoshHeaps.Net.csproj | 13 + JoshHeaps.Net/Models/GameState.cs | 4 +- JoshHeaps.Net/Program.cs | 4 + JoshHeaps.Net/Properties/launchSettings.json | 2 + JoshHeaps.Net/Resources/chess_engine.dll | Bin 0 -> 146432 bytes .../Implementations/ChessEngineFactory.cs | 32 + .../Implementations/ChessEngineHelpers.cs | 130 +++ .../ComputerMoveOrchestrator.cs | 33 + .../Implementations/CustomChessEngine.cs | 118 +++ .../Services/Implementations/Stockfish.cs | 154 +--- .../Services/Interfaces/IChessEngine.cs | 19 + .../Interfaces/IChessEngineFactory.cs | 14 + .../Interfaces/IComputerMoveOrchestrator.cs | 18 + JoshHeaps.Net/appsettings.Development.json | 3 + JoshHeaps.Net/appsettings.json | 3 + docs/custom-chess-engine-rnd.md | 759 ++++++++++++++++++ native/chess_engine/CMakeLists.txt | 42 + .../chess_engine/chess_engine.vcxproj | 179 +++++ .../chess_engine/chess_engine.vcxproj.filters | 66 ++ native/chess_engine/include/chess_engine.h | 80 ++ native/chess_engine/src/bitboard.cpp | 146 ++++ native/chess_engine/src/bitboard.h | 87 ++ native/chess_engine/src/chess_engine.cpp | 283 +++++++ native/chess_engine/src/movegen.cpp | 134 ++++ native/chess_engine/src/movegen.h | 17 + native/chess_engine/src/perft.cpp | 22 + native/chess_engine/src/perft.h | 14 + native/chess_engine/src/position.cpp | 347 ++++++++ native/chess_engine/src/position.h | 87 ++ native/chess_engine/src/types.h | 107 +++ native/chess_engine/src/uci.cpp | 50 ++ native/chess_engine/src/uci.h | 19 + native/chess_engine/src/zobrist.cpp | 39 + native/chess_engine/src/zobrist.h | 21 + native/chess_engine/test/perft_main.cpp | 76 ++ 42 files changed, 3033 insertions(+), 168 deletions(-) create mode 100644 JoshHeaps.Net.slnf create mode 100644 JoshHeaps.Net/Resources/chess_engine.dll create mode 100644 JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs create mode 100644 JoshHeaps.Net/Services/Implementations/ChessEngineHelpers.cs create mode 100644 JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs create mode 100644 JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs create mode 100644 JoshHeaps.Net/Services/Interfaces/IChessEngine.cs create mode 100644 JoshHeaps.Net/Services/Interfaces/IChessEngineFactory.cs create mode 100644 JoshHeaps.Net/Services/Interfaces/IComputerMoveOrchestrator.cs create mode 100644 docs/custom-chess-engine-rnd.md create mode 100644 native/chess_engine/CMakeLists.txt create mode 100644 native/chess_engine/chess_engine/chess_engine.vcxproj create mode 100644 native/chess_engine/chess_engine/chess_engine.vcxproj.filters create mode 100644 native/chess_engine/include/chess_engine.h create mode 100644 native/chess_engine/src/bitboard.cpp create mode 100644 native/chess_engine/src/bitboard.h create mode 100644 native/chess_engine/src/chess_engine.cpp create mode 100644 native/chess_engine/src/movegen.cpp create mode 100644 native/chess_engine/src/movegen.h create mode 100644 native/chess_engine/src/perft.cpp create mode 100644 native/chess_engine/src/perft.h create mode 100644 native/chess_engine/src/position.cpp create mode 100644 native/chess_engine/src/position.h create mode 100644 native/chess_engine/src/types.h create mode 100644 native/chess_engine/src/uci.cpp create mode 100644 native/chess_engine/src/uci.h create mode 100644 native/chess_engine/src/zobrist.cpp create mode 100644 native/chess_engine/src/zobrist.h create mode 100644 native/chess_engine/test/perft_main.cpp diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1dc9e2b..26c0e01 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,9 +24,9 @@ jobs: with: dotnet-version: '8.0.x' # adjust if needed - name: Restore - run: dotnet restore + run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj - name: Publish - run: dotnet publish -c Release -o ./publish + run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 54abbd2..edcd0a9 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -19,6 +19,6 @@ jobs: with: dotnet-version: 8.0.x - name: Restore dependencies - run: dotnet restore + run: dotnet restore JoshHeaps.Net.slnf - name: Build - run: dotnet build --no-restore + run: dotnet build JoshHeaps.Net.slnf --no-restore diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml index 023f8e4..6a6fc01 100644 --- a/.github/workflows/playwright-tests.yml +++ b/.github/workflows/playwright-tests.yml @@ -19,10 +19,10 @@ jobs: dotnet-version: 8.0.x - name: Restore dependencies - run: dotnet restore + run: dotnet restore JoshHeaps.Net.slnf - name: Build solution - run: dotnet build --no-restore + run: dotnet build JoshHeaps.Net.slnf --no-restore - name: Install Playwright browsers run: | diff --git a/.gitignore b/.gitignore index 9491a2f..e8379fe 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,7 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd + +# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/) +native/**/build/ \ No newline at end of file diff --git a/JoshHeaps.Net.sln b/JoshHeaps.Net.sln index 270423a..866e59e 100644 --- a/JoshHeaps.Net.sln +++ b/JoshHeaps.Net.sln @@ -1,26 +1,60 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.9.34728.123 +# Visual Studio Version 18 +VisualStudioVersion = 18.6.11822.322 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.Net\JoshHeaps.Net.csproj", "{9F0182CC-470F-4D1A-99F5-348D7921751E}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net.UiTests", "JoshHeaps.Net.UiTests\JoshHeaps.Net.UiTests.csproj", "{360264F4-8292-4EB3-B67D-98376C13438B}" EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "chess_engine", "native\chess_engine\chess_engine\chess_engine.vcxproj", "{2579BBBC-1830-4342-BC10-0A4182DC84C7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x64.ActiveCfg = Debug|Any CPU + {9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x64.Build.0 = Debug|Any CPU + {9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x86.ActiveCfg = Debug|Any CPU + {9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x86.Build.0 = Debug|Any CPU {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.ActiveCfg = Release|Any CPU {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.Build.0 = Release|Any CPU + {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.ActiveCfg = Release|Any CPU + {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.Build.0 = Release|Any CPU + {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.ActiveCfg = Release|Any CPU + {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.Build.0 = Release|Any CPU {360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.ActiveCfg = Debug|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.Build.0 = Debug|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.ActiveCfg = Debug|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.Build.0 = Debug|Any CPU {360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.ActiveCfg = Release|Any CPU {360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.Build.0 = Release|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.ActiveCfg = Release|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.Build.0 = Release|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.ActiveCfg = Release|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.Build.0 = Release|Any CPU + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.ActiveCfg = Debug|x64 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.Build.0 = Debug|x64 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.ActiveCfg = Debug|x64 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.Build.0 = Debug|x64 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.ActiveCfg = Debug|Win32 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.Build.0 = Debug|Win32 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.ActiveCfg = Release|x64 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.Build.0 = Release|x64 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.ActiveCfg = Release|x64 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.Build.0 = Release|x64 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.ActiveCfg = Release|Win32 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.Build.0 = Release|Win32 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/JoshHeaps.Net.slnf b/JoshHeaps.Net.slnf new file mode 100644 index 0000000..c6a1fc3 --- /dev/null +++ b/JoshHeaps.Net.slnf @@ -0,0 +1,9 @@ +{ + "solution": { + "path": "JoshHeaps.Net.sln", + "projects": [ + "JoshHeaps.Net\\JoshHeaps.Net.csproj", + "JoshHeaps.Net.UiTests\\JoshHeaps.Net.UiTests.csproj" + ] + } +} diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index 87a2042..29bf494 100644 --- a/JoshHeaps.Net/Controllers/ChessController.cs +++ b/JoshHeaps.Net/Controllers/ChessController.cs @@ -1,8 +1,6 @@ -using JoshHeaps.Net.Hubs; -using JoshHeaps.Net.Models; +using JoshHeaps.Net.Models; using JoshHeaps.Net.Services.Interfaces; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.SignalR; using System.Collections.Concurrent; namespace JoshHeaps.Net.Controllers; @@ -11,8 +9,9 @@ namespace JoshHeaps.Net.Controllers; [Route("api/[controller]")] public class ChessController( IChessService chessService, - IHubContext chessHub, - IBackgroundTaskQueue queue) : ControllerBase + IBackgroundTaskQueue queue, + IChessEngineFactory engineFactory, + IComputerMoveOrchestrator orchestrator) : ControllerBase { /// /// Store of ongoing games. @@ -42,7 +41,7 @@ public class ChessController( Guid computerId = Guid.NewGuid(); var isWhite = Random.Shared.Next(2) == 0; - gameState.Computer = new(difficulty); + gameState.Computer = engineFactory.Create(difficulty); if (isWhite) { @@ -57,7 +56,7 @@ public class ChessController( { // Give user's browser time to connect to signalR and such. await Task.Delay(TimeSpan.FromSeconds(1)); - await gameState.Computer.MakeMove(gameState, chessHub, chessService); + await orchestrator.PlayAsync(gameState, gameState.Computer!); }); } @@ -189,7 +188,7 @@ public class ChessController( ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout); if (gameState.IsVsComputer && gameState.Computer is not null) - queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService)); + queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!)); return Ok(result); } diff --git a/JoshHeaps.Net/JoshHeaps.Net.csproj b/JoshHeaps.Net/JoshHeaps.Net.csproj index 51f7fd2..a2064f5 100644 --- a/JoshHeaps.Net/JoshHeaps.Net.csproj +++ b/JoshHeaps.Net/JoshHeaps.Net.csproj @@ -5,6 +5,7 @@ enable enable 53ed685c-bdff-4306-8cc2-9fbe55c85713 + true @@ -28,4 +29,16 @@ + + + + false + false + + + diff --git a/JoshHeaps.Net/Models/GameState.cs b/JoshHeaps.Net/Models/GameState.cs index 6e8f468..63579f9 100644 --- a/JoshHeaps.Net/Models/GameState.cs +++ b/JoshHeaps.Net/Models/GameState.cs @@ -1,4 +1,4 @@ -using JoshHeaps.Net.Services.Implementations; +using JoshHeaps.Net.Services.Interfaces; namespace JoshHeaps.Net.Models; @@ -43,7 +43,7 @@ public class GameState public bool IsVsComputer { get; set; } = false; - public Stockfish? Computer { get; set; } + public IChessEngine? Computer { get; set; } // optional: convenience public bool IsOpen => !WhiteJoined || !BlackJoined; diff --git a/JoshHeaps.Net/Program.cs b/JoshHeaps.Net/Program.cs index d38fd23..4c0e0c8 100644 --- a/JoshHeaps.Net/Program.cs +++ b/JoshHeaps.Net/Program.cs @@ -22,6 +22,10 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.Configure(configuration.GetSection(ChessEngineOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + if (!builder.Environment.IsDevelopment()) builder.Services.AddHostedService(); diff --git a/JoshHeaps.Net/Properties/launchSettings.json b/JoshHeaps.Net/Properties/launchSettings.json index 99ea902..9acb43f 100644 --- a/JoshHeaps.Net/Properties/launchSettings.json +++ b/JoshHeaps.Net/Properties/launchSettings.json @@ -11,6 +11,7 @@ "profiles": { "http": { "commandName": "Project", + "nativeDebugging": true, "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "http://localhost:5200", @@ -20,6 +21,7 @@ }, "https": { "commandName": "Project", + "nativeDebugging": true, "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "https://localhost:7118;http://localhost:5200", diff --git a/JoshHeaps.Net/Resources/chess_engine.dll b/JoshHeaps.Net/Resources/chess_engine.dll new file mode 100644 index 0000000000000000000000000000000000000000..5167dea8ee8b1ccb418b5091b25b70a2916c96a3 GIT binary patch literal 146432 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 literal 0 HcmV?d00001 diff --git a/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs b/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs new file mode 100644 index 0000000..34b7c55 --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs @@ -0,0 +1,32 @@ +using JoshHeaps.Net.Services.Interfaces; +using Microsoft.Extensions.Options; + +namespace JoshHeaps.Net.Services.Implementations; + +/// The available chess engine implementations. +public enum ChessEngineKind +{ + Stockfish, + Custom +} + +/// Configuration selecting which to use. +public sealed class ChessEngineOptions +{ + public const string SectionName = "ChessEngine"; + + public ChessEngineKind Engine { get; set; } = ChessEngineKind.Stockfish; +} + +/// Creates the configured per game. +public sealed class ChessEngineFactory(IOptions 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}'.") + }; +} diff --git a/JoshHeaps.Net/Services/Implementations/ChessEngineHelpers.cs b/JoshHeaps.Net/Services/Implementations/ChessEngineHelpers.cs new file mode 100644 index 0000000..0845864 --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/ChessEngineHelpers.cs @@ -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, + }; + } + + /// + /// 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. + /// + 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)) + }; +} diff --git a/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs b/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs new file mode 100644 index 0000000..45d24df --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs @@ -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; + +/// +/// 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. +/// +public sealed class ComputerMoveOrchestrator( + IHubContext 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); + } +} diff --git a/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs b/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs new file mode 100644 index 0000000..64e94a7 --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs @@ -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; + +/// +/// Middleman wrapper over the native custom chess engine (chess_engine.dll / libchess_engine.so). +/// Shares with so the two are swappable. +/// The boundary contract is FEN string in, UCI move string out — identical to Stockfish. +/// +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 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; + } + + /// Guarantees the native handle is released exactly once via engine_destroy. + 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; + } + } + + /// + /// 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). + /// + 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); + } +} diff --git a/JoshHeaps.Net/Services/Implementations/Stockfish.cs b/JoshHeaps.Net/Services/Implementations/Stockfish.cs index 0f68109..82e9b43 100644 --- a/JoshHeaps.Net/Services/Implementations/Stockfish.cs +++ b/JoshHeaps.Net/Services/Implementations/Stockfish.cs @@ -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 _stdout = Channel.CreateUnbounded(); 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, 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, - }; - } - - /// - /// 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. - /// - 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)) - }; -} \ No newline at end of file diff --git a/JoshHeaps.Net/Services/Interfaces/IChessEngine.cs b/JoshHeaps.Net/Services/Interfaces/IChessEngine.cs new file mode 100644 index 0000000..4dc5d69 --- /dev/null +++ b/JoshHeaps.Net/Services/Interfaces/IChessEngine.cs @@ -0,0 +1,19 @@ +namespace JoshHeaps.Net.Services.Interfaces; + +/// +/// An AI move-selection engine (e.g. Stockfish or the custom native engine). +/// Selects a move for a position; it does not enforce rules or broadcast updates. +/// Implementations own unmanaged resources, hence . +/// +public interface IChessEngine : IAsyncDisposable +{ + /// The engine's playing strength / search depth. + int Skill { get; } + + /// + /// Returns the engine's chosen move in UCI long-algebraic form (e.g. "e2e4", "e7e8q"). + /// + /// The current position as a FEN string. + /// The selected move as a UCI string. + Task GetBestMoveAsync(string fen); +} diff --git a/JoshHeaps.Net/Services/Interfaces/IChessEngineFactory.cs b/JoshHeaps.Net/Services/Interfaces/IChessEngineFactory.cs new file mode 100644 index 0000000..3f87e24 --- /dev/null +++ b/JoshHeaps.Net/Services/Interfaces/IChessEngineFactory.cs @@ -0,0 +1,14 @@ +namespace JoshHeaps.Net.Services.Interfaces; + +/// +/// Creates instances, choosing the implementation from configuration. +/// +public interface IChessEngineFactory +{ + /// + /// Creates a new engine instance for a single game. The caller owns and disposes it. + /// + /// The desired playing strength / search depth. + /// A new, owned . + IChessEngine Create(int skill); +} diff --git a/JoshHeaps.Net/Services/Interfaces/IComputerMoveOrchestrator.cs b/JoshHeaps.Net/Services/Interfaces/IComputerMoveOrchestrator.cs new file mode 100644 index 0000000..6fcd34d --- /dev/null +++ b/JoshHeaps.Net/Services/Interfaces/IComputerMoveOrchestrator.cs @@ -0,0 +1,18 @@ +using JoshHeaps.Net.Models; + +namespace JoshHeaps.Net.Services.Interfaces; + +/// +/// Drives a computer move: asks the engine for a move, applies it through the rules +/// service, and broadcasts the result to the game's clients. +/// +public interface IComputerMoveOrchestrator +{ + /// + /// Has the engine pick a move for the current position, applies it, and broadcasts it. + /// + /// The game to play a move in. + /// The engine that selects the move. + /// The applied move and its result. + Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine); +} diff --git a/JoshHeaps.Net/appsettings.Development.json b/JoshHeaps.Net/appsettings.Development.json index 770d3e9..7c24bd5 100644 --- a/JoshHeaps.Net/appsettings.Development.json +++ b/JoshHeaps.Net/appsettings.Development.json @@ -5,5 +5,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "ChessEngine": { + "Engine": "Custom" } } diff --git a/JoshHeaps.Net/appsettings.json b/JoshHeaps.Net/appsettings.json index 90f577d..1b4fd6f 100644 --- a/JoshHeaps.Net/appsettings.json +++ b/JoshHeaps.Net/appsettings.json @@ -9,5 +9,8 @@ "BlogApi": { "BaseUrl": "https://media.joshheaps.net", "InvalidateKey": "CHANGE_ME" + }, + "ChessEngine": { + "Engine": "Custom" } } diff --git a/docs/custom-chess-engine-rnd.md b/docs/custom-chess-engine-rnd.md new file mode 100644 index 0000000..2cd1806 --- /dev/null +++ b/docs/custom-chess-engine-rnd.md @@ -0,0 +1,759 @@ +# R&D Findings: Swappable C++ Chess Engine via P/Invoke + +**Team:** Architect, Backend Engineer, Performance Engineer, DevOps Engineer (orchestrated by team lead) +**Date:** 2026-05-28 +**Status:** Complete — engine *infrastructure* design only; the C++ search logic is intentionally left as an empty, compilable stub for the user to implement. + +--- + +## Executive Summary + +We investigated how to host a custom chess engine written in C++ (compiled to `chess_engine.dll` for local Windows debugging and `libchess_engine.so` for the Linux server) behind a C# "middleman" wrapper that shares an interface with the existing `Stockfish.cs`, so the two are swappable by config. The key conclusion: the existing engine already uses a **string contract (FEN in → UCI move out)**, so the cleanest design mirrors it exactly — an opaque-handle `extern "C"` ABI, a `[LibraryImport]` P/Invoke wrapper, and a new `IChessEngine` interface that both `Stockfish` and the new `CustomChessEngine` implement, selected by a factory reading `appsettings`. The recommended next step is to land the **infrastructure** (interface, factory, P/Invoke wrapper, CMake project, empty C++ stub, build/packaging) and verify the round trip end-to-end with the stub returning a placeholder move — *before* any chess logic is written. + +> **Naming reconciliation:** the specialists used varying names for the native lib (`ChessEngine`, `chessengine`, `chess_engine`). This document standardizes on **logical name `chess_engine`** → `chess_engine.dll` (Windows) / `libchess_engine.so` (Linux), matching the Backend and DevOps proposals. Adjust the snippets from the Architect/Performance sections accordingly. + +### High-Level Architecture + +```mermaid +graph TD + subgraph Managed["C# / .NET 8"] + CTRL[ChessController] --> FAC[IChessEngineFactory] + FAC -->|reads appsettings| OPT[ChessEngineOptions] + FAC -->|Stockfish| SF[Stockfish : IChessEngine] + FAC -->|Custom| CE[CustomChessEngine : IChessEngine] + CTRL --> ORCH[IComputerMoveOrchestrator] + ORCH -->|GetBestMoveAsync fen| IFACE[/IChessEngine/] + SF -.implements.-> IFACE + CE -.implements.-> IFACE + ORCH --> RULES[IChessService rules] + ORCH --> HUB[ChessHub SignalR] + end + subgraph Native["C++ shared library"] + CE -->|P/Invoke once per move| ABI["extern C ABI
engine_best_move(fen, out)"] + ABI --> SEARCH["self-contained search
bitboards, zero managed callbacks"] + end + SF -->|child process stdio| SFEXE[stockfish-*.exe] +``` + +--- + +## Findings by Area + +### Architect: Engine Abstraction & Swap Mechanism + +#### Current State + +Two distinct concerns are easy to confuse: + +- **`IChessService` / `ChessService`** — the *rules engine* (move legality, check/mate). NOT the thing being swapped. `JoshHeaps.Net\Services\Interfaces\IChessService.cs:5-11`, `ChessService.cs:6`. +- **`Stockfish`** — the *AI move-selection engine* (the thing being swapped). `Services\Implementations\Stockfish.cs:13`. + +`Stockfish` is a concrete `sealed` class with **no interface**, carrying three mixed responsibilities: + +1. **Engine lifecycle/IO** — spawns a child process, UCI handshake, `GetBestMoveAsync(fen)`. `Stockfish.cs:13-111`. +2. **Orchestration** — `MakeMove(GameState, IHubContext, IChessService)` gets a move, converts it, calls the rules service, broadcasts over SignalR. `Stockfish.cs:113-126`. Engine-agnostic glue. +3. **FEN/UCI translation** — `StockfishHelpers.ToFen(...)` / `ToMoveDto(...)`. `Stockfish.cs:129-253`. Also engine-agnostic. + +Coupling points where the concrete type leaks: + +- `GameState.Computer` typed as concrete `Stockfish?` — domain model → implementation (inverted dependency). `Models\GameState.cs:46`, `using ...Implementations;` at `GameState.cs:1`. +- Controller hand-constructs the engine: `gameState.Computer = new(difficulty);` `Controllers\ChessController.cs:45`. No DI, no abstraction. +- Orchestration via concrete instance: `gameState.Computer.MakeMove(...)` at `ChessController.cs:60` and `:192`. +- Disposal via concrete type: `await game.Computer.DisposeAsync();` `ChessController.cs:248`. +- `Stockfish` is **not** registered in DI; `Program.cs:21-23` registers other services but the engine is `new`-ed inline per game. + +```mermaid +classDiagram + class GameState { + +Stockfish? Computer + +bool IsVsComputer + } + class ChessController + class Stockfish { + +GetBestMoveAsync(fen) Task~string~ + +MakeMove(state, hub, chessService) Task + +DisposeAsync() ValueTask + } + class StockfishHelpers { + +ToFen(GameState)$ string + +ToMoveDto(uci, state, playerId)$ MoveDto + } + class IChessService { <> } + class ChessService + + IChessService <|.. ChessService + ChessController --> GameState : new()s Stockfish into + GameState *-- Stockfish : owns concrete + ChessController ..> Stockfish : MakeMove() / DisposeAsync() + Stockfish ..> StockfishHelpers + Stockfish ..> IChessService + Stockfish ..> ChessHub : broadcasts + note for GameState "Models depends on\nServices.Implementations — inverted" + note for Stockfish "Orchestration lives inside engine —\nwould duplicate across 2 engines" +``` + +#### Findings + +1. **No interface → cannot swap.** `Stockfish` is `sealed`, concrete-only (`Stockfish.cs:13`). Adding `CustomChessEngine` today means touching `GameState`, the controller, and disposal. *Impact:* an `IChessEngine` abstraction is the core requirement. +2. **Domain model depends on a concrete service.** `GameState.cs:46` + `using ...Implementations` at `GameState.cs:1`. *Impact:* must become `IChessEngine?` (or leave the model entirely). +3. **Engine hand-constructed in controller, no DI.** `ChessController.cs:45`. *Impact:* engine selection can't be config-driven; this is the seam for a factory. +4. **Orchestration lives in the engine.** `Stockfish.MakeMove(...)` (`Stockfish.cs:113-126`) is not Stockfish-specific. *Impact:* copying it into `CustomChessEngine` duplicates rules-call + broadcast wiring; lift it to an orchestrator. +5. **FEN/UCI translation is engine-agnostic** (`Stockfish.cs:169`, `:131`). *Impact:* keep shared (rename to `ChessEngineHelpers`), don't duplicate. +6. **Engine lifecycle is game-scoped, not DI-scoped.** Created in `CreateGame` (`ChessController.cs:45`), disposed in the game-removal timer (`ChessController.cs:247-248`); the constructor spawns a process and blocks on UCI handshake (`Stockfish.cs:54,72,77`). *Impact:* a plain singleton won't fit — use a **per-game factory** producing disposable instances. +7. **`MakeMove` is fire-and-forget returning non-generic `Task`** (`Stockfish.cs:113`). *Impact:* when lifted, the orchestrator should return the `(MoveDto, MoveResultDto)` it produced (per CodingStyle "Void Avoidance"). + +#### Suggested Approach + +Three roles: `IChessEngine` (swappable contract), `IChessEngineFactory` (per-game creation from config), `IComputerMoveOrchestrator` (lifted glue). + +```csharp +// Services/Interfaces/IChessEngine.cs +public interface IChessEngine : IAsyncDisposable +{ + int Skill { get; } + // UCI long-algebraic, e.g. "e2e4", "e7e8q" + Task GetBestMoveAsync(string fen, CancellationToken cancellationToken = default); +} +``` + +```csharp +// Services/Implementations/Stockfish.cs (minimal change) +public sealed class Stockfish : IChessEngine // was: IAsyncDisposable +{ + public int Skill => _skill; + public Task GetBestMoveAsync(string fen, CancellationToken ct = default) { /* existing body */ } + public ValueTask DisposeAsync() { /* unchanged */ } + // DELETE MakeMove(...) -> moves to IComputerMoveOrchestrator +} +// StockfishHelpers -> rename to ChessEngineHelpers in Services/Implementations/ChessEngineHelpers.cs (body unchanged) +``` + +```csharp +// Services/Interfaces/IChessEngineFactory.cs +public interface IChessEngineFactory { IChessEngine Create(int skill); } + +// Services/Implementations/ChessEngineFactory.cs +public sealed class ChessEngineFactory(IOptions 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 engine '{_kind}'.") + }; +} +public enum ChessEngineKind { Stockfish, Custom } +public sealed class ChessEngineOptions +{ + public const string SectionName = "ChessEngine"; + public ChessEngineKind Engine { get; set; } = ChessEngineKind.Stockfish; +} +``` + +```csharp +// Program.cs (near :21-23) +builder.Services.Configure(configuration.GetSection(ChessEngineOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +``` + +```jsonc +// appsettings.json — flip to "Custom" to swap (override per-env in appsettings.Development.json) +{ "ChessEngine": { "Engine": "Stockfish" } } +``` + +```csharp +// Services/Implementations/ComputerMoveOrchestrator.cs (lifted from Stockfish.MakeMove, engine-agnostic) +public interface IComputerMoveOrchestrator +{ + Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine, CancellationToken ct = default); +} +public sealed class ComputerMoveOrchestrator(IHubContext chessHub, IChessService chessService) : IComputerMoveOrchestrator +{ + public async Task<(MoveDto, MoveResultDto)> PlayAsync(GameState state, IChessEngine engine, CancellationToken ct = default) + { + var uci = await engine.GetBestMoveAsync(state.ToFen(), ct); + 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, ct); + return (move, result); + } +} +``` + +**`GameState.Computer` recommendation:** keep the reference on `GameState` but retype to `IChessEngine?` (fixes Finding 2) — do *not* go full service-injection. Rationale: the engine is a stateful, per-game, disposable resource whose lifetime is managed by `ScheduleRemoveGame` (`ChessController.cs:247-248`); live games live in the controller's static `ConcurrentDictionary` (`ChessController.cs:20`), not DI. Only the engine's *construction* (factory) and *orchestration* (orchestrator) move out. + +```csharp +// ChessController changes (inject factory + orchestrator into the primary ctor at :12-15) +gameState.Computer = engineFactory.Create(difficulty); // was :45 new(difficulty) +await orchestrator.PlayAsync(gameState, gameState.Computer); // was :60 +if (gameState.IsVsComputer && gameState.Computer is not null) // was :192 + queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!)); +await game.Computer.DisposeAsync(); // :248 unchanged (IChessEngine : IAsyncDisposable) +``` + +```mermaid +classDiagram + class IChessEngine { + <> + +int Skill + +GetBestMoveAsync(fen, ct) Task~string~ + +DisposeAsync() ValueTask + } + class Stockfish + class CustomChessEngine { -P/Invoke chess_engine (.dll/.so) } + class IChessEngineFactory { <> +Create(skill) IChessEngine } + class ChessEngineFactory + class ChessEngineOptions { +ChessEngineKind Engine } + class IComputerMoveOrchestrator { <> +PlayAsync(state, engine, ct) } + class ComputerMoveOrchestrator + class GameState { +IChessEngine? Computer } + class ChessController + class ChessEngineHelpers + + IChessEngine <|.. Stockfish + IChessEngine <|.. CustomChessEngine + IChessEngineFactory <|.. ChessEngineFactory + IComputerMoveOrchestrator <|.. ComputerMoveOrchestrator + ChessEngineFactory ..> ChessEngineOptions : reads config + ChessEngineFactory ..> Stockfish : creates + ChessEngineFactory ..> CustomChessEngine : creates + ChessController ..> IChessEngineFactory + ChessController ..> IComputerMoveOrchestrator + GameState o-- IChessEngine : holds abstraction + ComputerMoveOrchestrator ..> IChessEngine + ComputerMoveOrchestrator ..> ChessEngineHelpers + ComputerMoveOrchestrator ..> IChessService + note for GameState "Now depends on Interfaces (DIP fixed)" + note for CustomChessEngine "C++ body empty; P/Invoke contract only" +``` + +#### Open Questions + +1. Does the custom engine speak FEN-in / UCI-out? (Assumed yes — confirm before locking the interface.) +2. Per-game instance vs shared singleton for the in-process native engine (a `.dll`/`.so` may be cheap enough to share, unlike a Stockfish process). +3. Skill/options surface — `int skill` vs an `EngineOptions` object if tunables diverge from Stockfish's `(skill, hash)` (`Stockfish.cs:20`). +4. Cancellation/timeout — interface has a `CancellationToken` but nothing wires it today (`BackgroundTaskQueue.cs:11` fire-and-forgets). +5. Concurrency — `GetBestMoveAsync` is not re-entrant per instance (single stdout `Channel`, `Stockfish.cs:17`); per-game instances make this moot today. + +--- + +### Backend Engineer: Native Interop & I/O Contract + +#### Current State + +The load-bearing method is `GetBestMoveAsync(string fen)` at `Stockfish.cs:80-95`: writes `position fen ` + `go depth N` to stdin (`:82-83`), reads stdout until a line starts with `bestmove` (`:88`), returns the second token — a raw UCI string like `e2e4` / `e7e8q` (`:90`). Input is produced by `ToFen()` (`Stockfish.cs:169-214`); output is consumed by `ToMoveDto()` (`Stockfish.cs:131-162`, parses chars `uci[0..4]` into `MoveDto`, `MoveDto.cs:3-20`). Consumer: `ChessController.cs:191-192` queues `gameState.Computer.MakeMove(...)`. + +**There is no native interop today.** A repo-wide grep for `DllImport|LibraryImport|NativeLibrary|Marshal|extern` returns only an unrelated hit in vendored `jquery.js`. Project targets `net8.0`, `Nullable=enable`, `ImplicitUsings=enable` (`JoshHeaps.Net.csproj:3-7`). + +```mermaid +sequenceDiagram + participant C as ChessController + participant SF as Stockfish (C#) + participant P as stockfish.exe (subprocess) + C->>SF: MakeMove(state) / GetBestMoveAsync(fen) + Note over SF: state.ToFen() builds FEN + SF->>P: stdin "position fen " + SF->>P: stdin "go depth N" + P-->>SF: stdout "info ..." + P-->>SF: stdout "bestmove e2e4" + Note over SF: Split(' ')[1] => "e2e4" + SF-->>C: "e2e4" (UCI) + Note over C: uci.ToMoveDto(state) => MoveDto +``` + +#### Findings + +1. **The boundary is already a pure string pair** (`Stockfish.cs:80,90`). *Impact:* the native ABI should mirror it exactly — `const char* fen` in, `char*` UCI out. No struct marshalling needed. +2. **No `IChessEngine` abstraction; consumers bind the concrete type** (`GameState.cs:46`, `ChessController.cs:191-192`). *Impact:* the middleman implements the Architect's interface; the `GameState` retype is a cross-cutting dependency. +3. **`MakeMove` mixes engine + SignalR/board concerns** (`Stockfish.cs:113-126`). *Impact:* keep it shared (orchestrator), not per-engine. The native-specific surface of `CustomChessEngine` is only `GetBestMoveAsync`. +4. **The UCI string is the contract anchor.** As long as the native engine emits a 4-or-5-char UCI move, the whole downstream pipeline (`MoveDto` → `IChessService.MakeMove` → SignalR) is unchanged (`Stockfish.cs:131,169`). +5. **Resource-shipping pattern is established** (`csproj:14-18` copies `Resources/**`). *Impact:* the `.dll`/`.so` ship the same way. + +#### Suggested Approach + +**Contract: strings (FEN in / UCI out), not a binary struct.** It is byte-identical to today's contract (so `ToFen`/`ToMoveDto` are untouched); FEN/UCI are stable ASCII (no layout/packing/endianness/enum-width to keep in sync); the user only writes string parsing in C++; per-move data is tiny. **Buffer-ownership rule:** the C# caller owns the output buffer; the engine only writes into it and never allocates returned strings — sidesteps cross-allocator free bugs. + +```c +// native/chess_engine/include/chess_engine.h +#ifndef CHESS_ENGINE_H +#define CHESS_ENGINE_H +#include + +#if defined(_WIN32) + #ifdef CHESS_ENGINE_BUILD + #define CHESS_API __declspec(dllexport) + #else + #define CHESS_API __declspec(dllimport) + #endif + #define CHESS_CALL __cdecl +#else + #define CHESS_API __attribute__((visibility("default"))) + #define CHESS_CALL +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct ChessEngine* EngineHandle; // opaque; host never dereferences + +enum { + CHESS_OK = 0, CHESS_ERR_NULL_HANDLE = -1, CHESS_ERR_BAD_FEN = -2, + CHESS_ERR_NO_MOVE = -3, CHESS_ERR_BUFFER = -4, CHESS_ERR_INTERNAL = -5 +}; + +CHESS_API EngineHandle CHESS_CALL engine_create(const char* options); // options e.g. "skill=20;hash=256" or NULL +CHESS_API int CHESS_CALL engine_set_option(EngineHandle, const char* name, const char* value); +CHESS_API int CHESS_CALL engine_best_move(EngineHandle, const char* fen, char* out_buf, int out_len); // writes "e2e4\0" +CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len); +CHESS_API void CHESS_CALL engine_destroy(EngineHandle); // safe with NULL + +#ifdef __cplusplus +} +#endif +#endif +``` + +```cpp +// native/chess_engine/src/chess_engine.cpp — EMPTY stub; compiles, returns placeholder +#define CHESS_ENGINE_BUILD +#include "chess_engine.h" +#include +#include + +struct ChessEngine { std::string options; }; // put TT, tables, etc. here later + +static int copy_out(const char* src, char* out, int cap) { + if (!out || cap <= 0) return CHESS_ERR_BUFFER; + const size_t need = std::strlen(src) + 1; + if (need > (size_t)cap) return CHESS_ERR_BUFFER; + std::memcpy(out, src, need); + return CHESS_OK; +} + +extern "C" { +CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) { + auto* e = new (std::nothrow) ChessEngine(); + if (e && options) e->options = options; + return e; +} +CHESS_API int CHESS_CALL engine_set_option(EngineHandle e, const char*, const char*) { + return e ? CHESS_OK : CHESS_ERR_NULL_HANDLE; +} +CHESS_API int CHESS_CALL engine_best_move(EngineHandle e, const char* fen, char* out, int cap) { + if (!e) return CHESS_ERR_NULL_HANDLE; + if (!fen || !*fen) return CHESS_ERR_BAD_FEN; + // TODO: parse fen, search, produce a real UCI move. + return copy_out("e2e4", out, cap); // placeholder +} +CHESS_API int CHESS_CALL engine_version(char* out, int cap) { return copy_out("custom-engine 0.0.1-stub", out, cap); } +CHESS_API void CHESS_CALL engine_destroy(EngineHandle e) { delete e; } +} +``` + +**ABI notes:** `extern "C"` kills name mangling; `CHESS_API` = `__declspec(dllexport)` (MSVC, when `CHESS_ENGINE_BUILD` defined) or `__attribute__((visibility("default")))` (GCC/Clang, pair with `-fvisibility=hidden`); `CHESS_CALL` pins `__cdecl` on Windows, empty (SysV default) on Linux. + +```csharp +// Services/Implementations/CustomChessEngine.cs +public sealed class CustomChessEngine : IChessEngine // IAsyncDisposable via IChessEngine +{ + private readonly EngineSafeHandle _handle; + + public int Skill { get; } + public CustomChessEngine(int skill = 20) + { + Skill = skill; + var raw = NativeMethods.engine_create($"skill={skill}"); + if (raw == IntPtr.Zero) throw new InvalidOperationException("engine_create returned null."); + _handle = new EngineSafeHandle(raw); + } + + public Task GetBestMoveAsync(string fen, CancellationToken ct = default) + => Task.Run(() => BestMove(fen), ct); // native call is sync + CPU-bound; offload off request thread + + private string BestMove(string fen) + { + Span outBuf = stackalloc byte[16]; // UCI <= 5 chars + NUL + bool added = false; + try + { + _handle.DangerousAddRef(ref added); + int rc; + unsafe { fixed (byte* p = outBuf) rc = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, p, outBuf.Length); } + ThrowIfError(rc); + int nul = outBuf.IndexOf((byte)0); + return System.Text.Encoding.ASCII.GetString(outBuf[..(nul < 0 ? outBuf.Length : nul)]); + } + finally { if (added) _handle.DangerousRelease(); } + } + + private static void ThrowIfError(int rc) { if (rc != 0) throw rc switch { + -2 => new ArgumentException("CHESS_ERR_BAD_FEN"), + -3 => new InvalidOperationException("No move (mate/stalemate)"), + -4 => new InvalidOperationException("Output buffer too small"), + _ => new InvalidOperationException($"Native engine error {rc}") }; } + + public ValueTask DisposeAsync() { _handle.Dispose(); return ValueTask.CompletedTask; } + + private sealed class EngineSafeHandle : SafeHandle + { + public EngineSafeHandle(IntPtr h) : base(IntPtr.Zero, true) => SetHandle(h); + public override bool IsInvalid => handle == IntPtr.Zero; + protected override bool ReleaseHandle() { NativeMethods.engine_destroy(handle); return true; } + } + + private static partial class NativeMethods + { + private const string Lib = "chess_engine"; // -> chess_engine.dll / libchess_engine.so + static NativeMethods() => NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, Resolve); + private static IntPtr Resolve(string name, Assembly asm, DllImportSearchPath? path) + { + if (name != Lib) return IntPtr.Zero; + string file = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "chess_engine.dll" : "libchess_engine.so"; + string probe = Path.Combine(AppContext.BaseDirectory, "Resources", file); + return File.Exists(probe) && NativeLibrary.TryLoad(probe, out var h) ? h : NativeLibrary.Load(name, asm, path); + } + + [LibraryImport(Lib, StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] + internal static partial IntPtr engine_create(string? options); + + [LibraryImport(Lib, StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] + internal static unsafe partial int engine_best_move(IntPtr engine, string fen, byte* outBuf, int outLen); + + [LibraryImport(Lib)] + [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] + internal static partial void engine_destroy(IntPtr engine); + } +} +``` + +**Why these C# choices:** `[LibraryImport]` (source-generated, AOT/trim-safe, no runtime IL stub, compile-time diagnostics) over `[DllImport]`; `StringMarshalling.Utf8` matches `const char*`; a `DllImportResolver` probes `Resources/` first then falls back to default search; `SafeHandle` guarantees `engine_destroy` runs exactly once; return codes map to typed exceptions; `Task.Run` adapts the sync native call to the async interface (for a real long search, prefer one dedicated long-running thread per instance over thread-pool churn). + +```mermaid +sequenceDiagram + participant C as ChessController + participant CE as CustomChessEngine (C#) + participant TP as Task.Run (threadpool) + participant N as chess_engine.dll/.so + C->>CE: GetBestMoveAsync(state.ToFen()) + CE->>TP: offload sync native call + Note over TP: stackalloc byte[16] out_buf (host-owned) + TP->>N: engine_best_move(handle, fen, out_buf, 16) + Note over N: parse FEN, search, write "e2e4\0" + N-->>TP: CHESS_OK; out_buf filled + TP-->>CE: "e2e4" + CE-->>C: "e2e4" (same UCI as Stockfish) + Note over C: uci.ToMoveDto(state) => MoveDto (unchanged) +``` + +#### Open Questions + +1. Should `MakeMove`/orchestration be on the interface or shared via the orchestrator? (Recommend orchestrator — aligns with Architect.) +2. Who owns the `GameState.Computer` retype (Architect vs Backend)? Cross-cutting. +3. Build/packaging: CMake + MSBuild copy vs commit binaries (see DevOps). +4. Search timeout/cancellation: add `engine_stop(handle)` + token-aware wrapper, or fixed-depth like Stockfish's `go depth N` (`Stockfish.cs:83`)? +5. `options` string format (`"key=value;..."`) vs per-option `engine_set_option`? +6. Concurrency model — one move per instance at a time (current queue suggests yes)? + +--- + +### Performance Engineer: Interop Boundary & Hot-Path Strategy + +#### Current State + +Two distinct things are being conflated: + +1. **The Stockfish path is already fast and well-structured** — one warm process (`Stockfish.cs:40-54`), two text commands per move, block on stdout (`:82-93`). The cost is the *search* (`go depth {_skill}`, `:83`), not the pipe. **Stockfish is not slow.** It is also already a self-contained native search — the model to preserve. +2. **The C# `ChessService` is the genuinely slow thing** and the real motivation: + - **Object-graph board:** `GameState.Board` is `ChessPiece?[,]` (`GameState.cs:11`) of heap `ChessPiece` objects each with a `string Id` (`ChessPiece.cs:5`) — pointer chase / cache miss per square touch. + - **Allocation per pseudo-move:** legality calls `CloneGameState` (`ChessService.cs:319,453-483`) allocating a new `GameState`, `ChessPiece[8,8]`, `List`, and a `ChessPiece` per piece — *every candidate move*. + - **LINQ in the inner loop:** `IsSquareAttacked` does `.Where(...).ToList()` + regenerates enemy moves (`ChessService.cs:396-407`); lookups by string id via `FirstOrDefault` (`:110,126`). + - Fine for validating one human move; orders of magnitude away from a search loop. Correct thing to move to C++. + +```mermaid +flowchart TD + A["ChessController.MakeMove :192"] --> B["queue.Queue (IBackgroundTaskQueue)"] + B --> C["Stockfish.MakeMove :113"] + C --> D["state.ToFen() ~70B :169"] + D --> E["stdin 'position fen' + 'go depth N' :82-83"] + E --> F["stdin pipe -> stockfish.exe"] + F --> G["NATIVE SEARCH (seconds)
millions of nodes, zero managed calls"] + G --> H["stdout 'bestmove e2e4' :86-92"] + H --> I["parse ~5B UCI -> MoveDto :117,136"] + style G fill:#2d6a2d,color:#fff + style F fill:#7a5c00,color:#fff + style H fill:#7a5c00,color:#fff +``` + +#### Findings + +1. **The move boundary is provably not the hot path.** Per move: ~70-byte FEN in, ~5-byte UCI out — sub-microsecond marshalling vs a multi-second search; the boundary is ~6+ orders of magnitude cheaper than the work it gates. The string contract is correct and will never bottleneck. Production already proves it via a *heavier* transport (OS pipes). +2. **Forbidden anti-pattern: a chatty per-node boundary.** A `LibraryImport` P/Invoke transition is ~1–2 ns, but a search visits millions of nodes/sec. A managed callback per node (move-gen/eval) adds a GC-tracked frame, write-barrier exposure, and loss of native inlining on the hottest loop — defeating the whole point. **Rule: the native search owns move-gen, make/unmake, and eval; zero managed callbacks below the once-per-move boundary.** +3. **Board representation is native-internal, not a marshalling concern.** Use **bitboards** inside C++ (~12 `uint64_t` + occupancy/flags); make/unmake and attacks become `&`/`|`/shifts/`popcnt`/`tzcnt` instead of pointer-chasing + LINQ (`ChessService.cs:392-410`). None of it crosses the boundary — C# keeps its `GameState` graph for rendering/human-move validation; the engine rebuilds bitboards from the FEN. +4. **Threading/async.** The search is CPU-bound/synchronous; the controller already enqueues on a background queue (`ChessController.cs:192`) so the SignalR thread isn't blocked. Wrap the blocking P/Invoke in `Task.Run`; results push back over `IHubContext` as today (`Stockfish.cs:125`). **Parallel search (Lazy SMP) stays 100% native.** **Cancellation = one atomic flag:** `engine_stop()` sets `std::atomic` polled between nodes — crosses the boundary once on cancel, never per node. + +| Operation | Approx. cost | Frequency | +|---|---|---| +| P/Invoke transition (blittable, `LibraryImport`) | ~1–2 ns | once per move | +| Marshal ~70B FEN in + ~5B UCI out | < 1 µs | once per move | +| `ToFen()` string build (`Stockfish.cs:169`) | low µs | once per move | +| **Native search (`go depth N`)** | **~0.1–several s** | **once per move** | +| Hypothetical managed eval callback **per node** | ~tens of ns × millions/sec | ❌ never — forbidden | + +#### Suggested Approach + +Cross the boundary **once per move**; keep the loop fully native. + +```cpp +// RECOMMENDED: self-contained native search +extern "C" int engine_best_move(ChessEngine* e, const char* fen, char* out, int cap) { + Position pos = parse_fen(fen); // build bitboards ONCE + g_stop.store(false); + Move best = search(pos, e->depth); // millions of nodes, NO callbacks out + return write_uci(best, out, cap); // ~5 bytes back +} +``` +```cpp +// FORBIDDEN: chatty boundary — do NOT do this +int search(Position& pos, int depth) { + for (Move m : managed_generate_moves(pos)) // P/Invoke OUT per node + eval += managed_eval_callback(pos); // managed frame per node — death +} +``` + +**Optional blittable-struct contract — recommend DEFER.** If profiling ever showed FEN parse dominating (it won't at one call/move), you *could* pass a `[StructLayout(LayoutKind.Sequential)]` `NativePosition` (12 bitboards + flags) by `in`/`ref` (fully blittable, no marshalling). But it adds a second board-layout source-of-truth and couples C# to the engine's internals. **Ship the FEN/UCI string contract; don't build the struct path until a profiler proves it's needed.** + +```mermaid +flowchart TD + A["ChessController.MakeMove :192"] --> B["queue.Queue -> Task.Run (SignalR thread freed)"] + B --> C["state.ToFen() ~70B"] + C --> D{{"engine_best_move(handle, fen, buf)
ONE P/Invoke crossing (~1-2ns + <1us marshal)"}} + D --> E["NATIVE: parse_fen -> bitboards (x12 ulong)"] + E --> F["NATIVE SEARCH LOOP
make/unmake on bitboards, native eval
Lazy SMP threads, poll atomic g_stop
ZERO managed callbacks"] + F --> G{{"return ~5B UCI move
ONE crossing back"}} + G --> H["parse UCI -> MoveDto -> SignalR (Stockfish.cs:117-125)"] + I["engine_stop(handle)"] -. "once on cancel, NOT per node" .-> F + style D fill:#1f4e79,color:#fff + style G fill:#1f4e79,color:#fff + style E fill:#2d6a2d,color:#fff + style F fill:#2d6a2d,color:#fff +``` + +**Build flags (coordinate with DevOps):** MSVC `/O2 /GL` + `/LTCG`, `/arch:AVX2` (matches the shipped AVX2 Stockfish, `Stockfish.cs:25`); GCC/Clang `-O3 -flto` with a baseline `-march` the server supports (the Linux Stockfish targets `sse41-popcnt`, `Stockfish.cs:28`) — avoid `-march=native` on a build host differing from the server. + +#### Open Questions + +1. **Deploy CPU baseline** — server's CPU floor dictates safe `-march`/`/arch` and whether `popcnt`/AVX2 bitboard intrinsics are guaranteed (Stockfish picks `avx2` Win / `sse41-popcnt` Linux, `Stockfish.cs:24-28`). +2. Will the native engine fully replace or coexist with Stockfish? (Recommend a shared `IChessEngine`.) +3. Engine lifetime/concurrency — one handle per game (like Stockfish today) vs reused across games (needs re-entrancy)? +4. Search termination — fixed depth, nodes, or wall-clock (time-based makes `engine_stop` most useful)? +5. Move-legality ownership — human moves validated by `ChessService.MakeMove` (`ChessController.cs:179`); two move generators risk divergence. + +--- + +### DevOps Engineer: Cross-Platform Native Build & Packaging + +#### Current State + +- **Packaging:** `JoshHeaps.Net.csproj:14-18` copies the whole Resources folder as `Content` with `CopyToOutputDirectory=PreserveNewest` → lands in `bin\\net8.0\Resources\` and `publish\Resources\`. Binaries are **committed** (`Resources\stockfish-windows-x86-64-avx2.exe`, `Resources\stockfish-ubuntu-x86-64-sse41-popcnt`). +- **Runtime load:** `Stockfish.cs:23` `AppContext.BaseDirectory`; `:24-28` OS filename switch; `:30` `Path.Combine(baseDir,"Resources",fileName)`; `:32-36` existence check. Stockfish is a **child process** (`:40-54`), so there is no native-library load path today. +- **CI/deploy is Linux-only:** `.github\workflows\deploy.yml:17` `runs-on: ubuntu-latest`, `dotnet publish -c Release` (`:29`), `rsync -az --delete publish/` to the server (`:55-58`), systemd restart (`:60-64`). PR build also `ubuntu-latest` (`dotnet.yml:13`). **A Windows `.dll` can never be produced on the runner — it must be committed.** + +```mermaid +flowchart TD + A["Resources/stockfish-windows-*.exe (committed)"] --> C + B["Resources/stockfish-ubuntu-* (committed)"] --> C + C["csproj Content Resources/** PreserveNewest (csproj:14-18)"] + C --> D["dotnet publish (ubuntu-latest) deploy.yml:29"] + D --> E["publish/Resources/*"] + E --> F["rsync to Linux server deploy.yml:55-58"] + F --> G["Runtime Stockfish.cs:23,30 BaseDirectory + Resources/fileName"] + G --> H["Process.Start(exePath) — child process, not P/Invoke :40-54"] +``` + +#### Findings + +1. **The existing Content glob already covers new Resources files** (`csproj:14-18`) — dropping the native libs in `Resources/` flows them to output/publish with zero csproj change required (explicit items optional for clarity). +2. **Build host is Linux-only** (`deploy.yml:17`, `dotnet.yml:13`) — the Windows `.dll` MUST be committed; an MSBuild→CMake target only helps on a developer's Windows box. +3. **Stockfish uses a child process, not P/Invoke** (`Stockfish.cs:40-54`) — no existing `DllImport`/`NativeLibrary` precedent; the resolver story is net-new. +4. **Resources path is hardcoded `Path.Combine(baseDir,"Resources",...)`** (`Stockfish.cs:30`), but P/Invoke's default search does NOT look in a `Resources` subfolder. *Impact:* register a `DllImportResolver` pointing at `Resources/` **or** place the lib at the output root. Biggest divergence from the Stockfish pattern. +5. **No CMake/C++ scaffolding exists** — greenfield; recommend `native/chess_engine/` at repo root, outside the csproj compile globs. +6. **glibc/libstdc++ ABI risk** — the committed `.so` is built on a dev/CI machine but runs on the rsync'd server (`deploy.yml:55`); a newer build-host libstdc++/glibc → runtime load failure. Build against a server-matching baseline or static-link libstdc++. + +#### Suggested Approach + +**Recommendation: build natively per-platform and commit both artifacts into `Resources/`, mirroring Stockfish.** The repo already commits platform binaries, the build host is Linux-only, and committing keeps deploy a pure `dotnet publish`. An optional opt-in MSBuild target can rebuild the matching-platform artifact locally, but must never be the deploy's source of truth. + +```cmake +# native/chess_engine/CMakeLists.txt +cmake_minimum_required(VERSION 3.20) +project(chess_engine LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +add_library(chess_engine SHARED src/chess_engine.cpp) +target_include_directories(chess_engine PUBLIC include) +target_compile_definitions(chess_engine PRIVATE CHESS_ENGINE_BUILD) + +# Windows -> chess_engine.dll ; Linux -> libchess_engine.so +set_target_properties(chess_engine PROPERTIES OUTPUT_NAME chess_engine POSITION_INDEPENDENT_CODE ON) +set(CMAKE_CXX_VISIBILITY_PRESET hidden) +set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) + +if (MSVC) + target_compile_options(chess_engine PRIVATE + $<$:/O2 /GL /DNDEBUG /arch:AVX2> + $<$:/Od /Zi>) # /Zi => .pdb for mixed-mode debugging + target_link_options(chess_engine PRIVATE $<$:/LTCG> $<$:/DEBUG>) +else() + # Portable server baseline; do NOT use -march=native (build host may differ -> SIGILL). + target_compile_options(chess_engine PRIVATE + $<$:-O3 -flto -DNDEBUG -march=x86-64-v2> # ~SSE4.2; confirm server floor + $<$:-O0 -g>) +endif() +``` + +The export macro (`CHESS_API`, defined under `CHESS_ENGINE_BUILD`) and `extern "C"` live in the Backend Engineer's `chess_engine.h`. + +```bash +# Windows (Developer PowerShell, MSVC) +cmake -S native/chess_engine -B native/chess_engine/build -A x64 +cmake --build native/chess_engine/build --config Release # -> build/Release/chess_engine.dll (+ .pdb) +# Linux (ideally in an ubuntu:22.04 container matching the server) +cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release +cmake --build native/chess_engine/build # -> build/libchess_engine.so +``` +Copy each artifact into `JoshHeaps.Net/Resources/` and commit — same lifecycle as the Stockfish binaries. + +**Optional local-only CMake build target** (gated `BuildNativeEngine=true`, off by default, OS-conditioned, never gates deploy): +```xml + + + + + + + + + + +``` + +**Runtime load — recommendation: `DllImportResolver` pointing at `Resources/`** (co-locates with Stockfish, survives single-file publish). This matches the Backend Engineer's `NativeMethods` resolver. Keep the native libs **only** in `Resources/` (rely on the existing `Content` glob) — do not also `` them to the output root, to avoid two copies that drift. + +**Local debugging (mixed-mode):** CMake `Debug` emits `/Zi` + `/DEBUG` → `chess_engine.pdb`; ship it next to the `.dll` for local debug builds only (Debug-only `None` item, never committed/deployed). In Visual Studio enable **Project Properties → Debug → Enable native code debugging** to step from C# P/Invoke into C++. + +**Linux deployment:** `libchess_engine.so` ships via `Resources/**` → `publish/` → rsync automatically. It's `dlopen`'d (no `chmod +x` needed) but must be world-readable. It links `libstdc++`/`glibc` — build against a baseline ≤ the server's, or **static-link** (`-static-libstdc++ -static-libgcc`) to remove the version coupling (safest for a committed binary). + +```mermaid +flowchart TD + subgraph Build["Build (per-platform)"] + W["Windows dev: cmake --build Release
=> chess_engine.dll (+pdb in Debug)"] + L["Linux (ubuntu:22.04 container): cmake --build Release
=> libchess_engine.so (-static-libstdc++)"] + end + W --> R["Commit into JoshHeaps.Net/Resources/ (as stockfish-* today)"] + L --> R + R --> CSP["csproj Content Resources/** (csproj:14-18) -> output/Resources/"] + CSP --> PUB["dotnet publish (ubuntu-latest) deploy.yml:29 — pure, no toolchain"] + PUB --> RS["rsync publish/ to server deploy.yml:55-58"] + RS --> RES["DllImportResolver: BaseDirectory + Resources/ + OS filename switch (mirrors Stockfish.cs:23-30)"] + RES --> PI["P/Invoke [LibraryImport(chess_engine)] -> NativeLibrary.Load"] + style W fill:#1d4ed8,color:#fff + style L fill:#15803d,color:#fff + style PI fill:#92400e,color:#fff +``` + +#### Open Questions + +1. **Server glibc/libstdc++ version unknown** — need `ldd --version` + `strings libstdc++.so.6 | grep GLIBCXX`; otherwise build in `ubuntu:22.04` or static-link libstdc++. +2. **Commit binaries vs build `.so` in CI?** Recommend commit both (consistent, pure deploy); alternative is a Linux native-build step in `deploy.yml` for a reproducible/ABI-correct `.so`. +3. **Target CPU baseline** — assumed `-march=x86-64-v2` to match `sse41-popcnt`; confirm the server floor; `-march=native` unsafe for committed/CI binaries. +4. **`IChessEngine` doesn't exist yet** — prerequisite from the Architect (out of scope for build, flagged). +5. **`.pdb` policy** — local-only Debug symbols recommended; confirm. + +--- + +## Cross-Cutting Concerns + +```mermaid +graph TD + subgraph Arch["Architect: abstraction"] + IFACE[/IChessEngine/] --> FAC[Factory + appsettings] + end + subgraph Backend["Backend: contract"] + ABI["extern C ABI
FEN -> UCI"] + end + subgraph Perf["Performance"] + ONCE["once-per-move boundary
native search"] + end + subgraph DevOps["DevOps"] + PKG["commit .dll/.so in Resources
DllImportResolver"] + end + IFACE -.->|"GetBestMoveAsync(fen) shape
must match ABI"| ABI + ABI -.->|"string contract = thin boundary"| ONCE + ONCE -.->|"build flags -O3/AVX2/LTO"| PKG + PKG -.->|"resolver finds lib for CustomChessEngine"| IFACE +``` + +1. **The string contract ties all four areas together.** `IChessEngine.GetBestMoveAsync(string fen) → string` (Architect) is the exact shape of `engine_best_move(const char* fen, char* out)` (Backend), which is what makes the boundary thin (Performance) and keeps `ToFen`/`ToMoveDto` untouched. If anyone changes to a struct contract, all four must change. Evidence: `Stockfish.cs:80,90,131,169`. +2. **`GameState.Computer` retype is owned by the Architect but unblocks Backend.** `GameState.cs:46` must become `IChessEngine?` before `CustomChessEngine` can be slotted in. Both specialists flagged it. +3. **Native-lib naming must be consistent end to end.** The logical name `chess_engine` (C# `[LibraryImport]`/resolver), the CMake `OUTPUT_NAME chess_engine`, and the committed filenames `chess_engine.dll` / `libchess_engine.so` must all agree. The specialists used different names — standardized here. +4. **`Resources/` placement + resolver is the load contract.** DevOps's `DllImportResolver` (probing `BaseDirectory/Resources`) and Backend's `NativeMethods.Resolve` are the same mechanism and must be written once (in `CustomChessEngine`/`NativeMethods`). Evidence: `Stockfish.cs:23,30`, `csproj:14-18`. +5. **Build optimization is a shared Performance/DevOps concern.** `-O3 -flto` / `/O2 /GL /LTCG`, `/arch:AVX2`, and a safe Linux `-march` baseline live in the CMakeLists (DevOps) but are motivated by the hot-loop requirement (Performance). +6. **Move-generation single-source-of-truth.** Human moves stay validated by C# `ChessService` (`ChessController.cs:179`); the native engine has its own generator. Two generators risk divergence — consider exposing native `perft` later for cross-validation. + +## Risk Assessment + +| Risk | Severity | Likelihood | Mitigation | Related Files | +|---|---|---|---|---| +| Linux `.so` fails to load (glibc/libstdc++ mismatch) | High | Med | Build in `ubuntu:22.04` container or static-link libstdc++; capture server `ldd --version` | `deploy.yml:55-58`, CMakeLists | +| Native crash takes down the ASP.NET process | High | Med | The empty stub returns codes, never throws across the boundary; validate FEN in C# first; consider process isolation if instability appears | `chess_engine.cpp`, `CustomChessEngine.cs` | +| Windows `.dll` can't be built in CI (Linux runner) | Med | High (by design) | Commit the `.dll` like the Stockfish `.exe`; optional opt-in local MSBuild target | `deploy.yml:17`, `csproj:14-18` | +| `-march`/`/arch` too aggressive → SIGILL on server | Med | Med | Use a confirmed server baseline; never `-march=native` for committed/CI binaries | CMakeLists, `Stockfish.cs:24-28` | +| P/Invoke can't find the lib (Resources subfolder not searched) | Med | High without resolver | `DllImportResolver` probing `BaseDirectory/Resources` | `CustomChessEngine.cs`, `Stockfish.cs:30` | +| Handle leak / double-free across boundary | Med | Low | `SafeHandle` + caller-owned out buffers + `delete nullptr`-safe `engine_destroy` | `CustomChessEngine.cs`, `chess_engine.cpp` | +| Concurrent `GetBestMoveAsync` on one non-reentrant handle | Low | Low | One handle per game (as today); document non-reentrancy | `GameState.cs:46`, `ChessController.cs:192` | +| Two move generators (C# rules vs native) diverge | Med | Med | Keep C# as legality authority; add native `perft` for cross-check later | `ChessService.cs`, native | + +## Recommendations + +Ordered by priority: + +1. **Introduce `IChessEngine` and retrofit `Stockfish`** (Architect §). Add the interface, make `Stockfish` implement it, rename `StockfishHelpers` → `ChessEngineHelpers`, retype `GameState.Computer` to `IChessEngine?`. Low effort, unblocks everything. *Supported by Findings A1–A2, B2.* +2. **Lift orchestration out of the engine** into `IComputerMoveOrchestrator` (Architect §; Backend Finding 3). Removes duplication before a second engine exists. Low effort. +3. **Add the factory + `appsettings` swap** (Architect §). `IChessEngineFactory` + `ChessEngineOptions`, registered in `Program.cs`. The swap mechanism the mission asks for. Low effort. +4. **Stand up the native project + empty stub** (Backend + DevOps §). `native/chess_engine/` with `chess_engine.h`, the compilable `chess_engine.cpp` placeholder, and `CMakeLists.txt`. Medium effort (build setup, not logic). +5. **Write `CustomChessEngine` P/Invoke wrapper** (Backend §) implementing `IChessEngine`, with `[LibraryImport]`, `DllImportResolver` → `Resources/`, `SafeHandle`, and `Task.Run` async adaptation. Medium effort. +6. **Build + commit both artifacts; verify the round trip** (DevOps §). Build `.dll` on Windows / `.so` on Linux (container), commit to `Resources/`, flip `appsettings` to `Custom`, confirm the stub's `e2e4` flows through `ToMoveDto` → SignalR end to end. Medium effort. **Do this before writing any chess logic.** +7. **Then implement the C++ engine** (user) — bitboards, search, eval, all native, honoring the "zero managed callbacks per node" rule (Performance §). The infrastructure above makes this a pure C++ task behind a stable contract. + +## Appendix: All Referenced Files + +| File | Referenced By | Context | +|---|---|---| +| `Services/Implementations/Stockfish.cs` | Architect, Backend, Performance, DevOps | The engine to mirror; `GetBestMoveAsync`/`MakeMove`/`ToFen`/`ToMoveDto`, process launch, Resources path | +| `Services/Interfaces/IChessService.cs` | Architect, Backend | Rules engine (separate concern, not swapped) | +| `Services/Implementations/ChessService.cs` | Architect, Performance | C# move-gen/legality — the genuinely slow code being replaced | +| `Models/GameState.cs` | Architect, Backend, Performance | `Computer` coupling (`:46`), board model (`:11`) | +| `Models/ChessPiece.cs`, `Position.cs`, `MoveDto.cs`, `Enums.cs` | Backend, Performance | Data shapes; UCI→`MoveDto` parsing | +| `Controllers/ChessController.cs` | Architect, Backend, Performance | Engine construction (`:45`), invocation (`:60,192`), disposal (`:248`), game registry (`:20`) | +| `Program.cs` | Architect | DI registration site (`:21-23`) | +| `appsettings.json` | Architect | Swap config section | +| `Services/Interfaces/IBackgroundTaskQueue.cs` | Performance | Background move execution | +| `JoshHeaps.Net.csproj` | Backend, DevOps | `Content Resources/**` copy (`:14-18`), TFM/Nullable (`:3-7`) | +| `Resources/stockfish-*` | DevOps | Committed-binary precedent for `.dll`/`.so` | +| `.github/workflows/deploy.yml`, `dotnet.yml` | DevOps | Linux-only CI, publish + rsync deploy | +| `native/chess_engine/include/chess_engine.h` (new) | Backend, DevOps | extern "C" ABI + export macro | +| `native/chess_engine/src/chess_engine.cpp` (new) | Backend | Empty compilable stub | +| `native/chess_engine/CMakeLists.txt` (new) | DevOps, Performance | Shared-lib build + optimization flags | +| `Services/Implementations/CustomChessEngine.cs` (new) | Backend, Architect | P/Invoke middleman implementing `IChessEngine` | +| `Services/Interfaces/IChessEngine.cs` + `IChessEngineFactory.cs` (new) | Architect | Swappable contract + factory | +| `Services/Implementations/ChessEngineFactory.cs` + `ComputerMoveOrchestrator.cs` (new) | Architect | Config-driven selection + lifted orchestration | diff --git a/native/chess_engine/CMakeLists.txt b/native/chess_engine/CMakeLists.txt new file mode 100644 index 0000000..af664b1 --- /dev/null +++ b/native/chess_engine/CMakeLists.txt @@ -0,0 +1,42 @@ +cmake_minimum_required(VERSION 3.20) +project(chess_engine LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# Shared library: chess_engine.dll (Windows) / libchess_engine.so (Linux). +add_library(chess_engine SHARED + src/chess_engine.cpp + src/bitboard.cpp + src/zobrist.cpp + src/position.cpp + src/movegen.cpp + src/uci.cpp + src/perft.cpp) +target_include_directories(chess_engine PUBLIC include) +target_compile_definitions(chess_engine PRIVATE CHESS_ENGINE_BUILD) + +set_target_properties(chess_engine PROPERTIES + OUTPUT_NAME chess_engine + POSITION_INDEPENDENT_CODE ON) # -fPIC on Linux (required for .so) + +# Export only the symbols marked with the CHESS_API macro. +set(CMAKE_CXX_VISIBILITY_PRESET hidden) +set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) + +if (MSVC) + target_compile_options(chess_engine PRIVATE + $<$:/O2 /GL /DNDEBUG /arch:AVX2> + $<$:/Od /Zi>) # /Zi => .pdb for mixed-mode debugging + target_link_options(chess_engine PRIVATE + $<$:/LTCG> + $<$:/DEBUG>) +else() + # Portable server baseline. Do NOT use -march=native: the build host may have + # instructions the server lacks (SIGILL at runtime). Bump only once the server + # CPU floor is confirmed. + target_compile_options(chess_engine PRIVATE + $<$:-O3 -flto -DNDEBUG -march=x86-64-v2> + $<$:-O0 -g>) +endif() diff --git a/native/chess_engine/chess_engine/chess_engine.vcxproj b/native/chess_engine/chess_engine/chess_engine.vcxproj new file mode 100644 index 0000000..5f99245 --- /dev/null +++ b/native/chess_engine/chess_engine/chess_engine.vcxproj @@ -0,0 +1,179 @@ + + + + + Debug + Win32 + + + Release + Win32 + + + Debug + x64 + + + Release + x64 + + + + 18.0 + Win32Proj + {2579bbbc-1830-4342-bc10-0a4182dc84c7} + chessengine + 10.0 + + + + DynamicLibrary + true + v145 + Unicode + + + DynamicLibrary + false + v145 + true + Unicode + + + DynamicLibrary + true + v145 + Unicode + + + DynamicLibrary + false + v145 + true + Unicode + + + + + + + + + + + + + + + + + + + + + + Level3 + true + WIN32;_DEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + stdcpp20 + NotUsing + $(ProjectDir)..\include;%(AdditionalIncludeDirectories) + + + Windows + true + false + + + true + + + + + Level3 + true + true + true + WIN32;NDEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + stdcpp20 + NotUsing + $(ProjectDir)..\include;%(AdditionalIncludeDirectories) + + + Windows + true + false + + + true + + + + + Level3 + true + _DEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + stdcpp20 + NotUsing + $(ProjectDir)..\include;%(AdditionalIncludeDirectories) + + + Windows + true + false + + + true + + + + + Level3 + true + true + true + NDEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + stdcpp20 + NotUsing + $(ProjectDir)..\include;%(AdditionalIncludeDirectories) + + + Windows + true + false + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/native/chess_engine/chess_engine/chess_engine.vcxproj.filters b/native/chess_engine/chess_engine/chess_engine.vcxproj.filters new file mode 100644 index 0000000..1e1cac6 --- /dev/null +++ b/native/chess_engine/chess_engine/chess_engine.vcxproj.filters @@ -0,0 +1,66 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + \ No newline at end of file diff --git a/native/chess_engine/include/chess_engine.h b/native/chess_engine/include/chess_engine.h new file mode 100644 index 0000000..0d494c6 --- /dev/null +++ b/native/chess_engine/include/chess_engine.h @@ -0,0 +1,80 @@ +/* chess_engine.h - C ABI for a swappable chess engine. + * + * Contract: FEN string in, UCI move string out (e.g. "e2e4", "e7e8q"). + * The C# host (CustomChessEngine) owns all buffers. The engine NEVER allocates + * memory that the host must free. Functions are thread-compatible per-handle only: + * do NOT call two functions on the SAME handle concurrently. Different handles + * are independent. + */ +#ifndef CHESS_ENGINE_H +#define CHESS_ENGINE_H + +#include + +/* ---- Export / calling-convention macro (MSVC + GCC/Clang) ---- */ +#if defined(_WIN32) + #ifdef CHESS_ENGINE_BUILD + #define CHESS_API __declspec(dllexport) + #else + #define CHESS_API __declspec(dllimport) + #endif + #define CHESS_CALL __cdecl /* explicit; matches C# CallingConvention.Cdecl */ +#else + #define CHESS_API __attribute__((visibility("default"))) + #define CHESS_CALL /* SysV default; no decoration needed */ +#endif + +#ifdef __cplusplus +extern "C" { /* prevent C++ name mangling */ +#endif + +/* Opaque handle. The host treats this as a token and never dereferences it. + * Internally it points to your engine state object. */ +typedef struct ChessEngine* EngineHandle; + +/* Return codes. 0 == success; negative == error. Keep these values stable. */ +enum { + CHESS_OK = 0, + CHESS_ERR_NULL_HANDLE = -1, /* handle was null/invalid */ + CHESS_ERR_BAD_FEN = -2, /* fen failed to parse */ + CHESS_ERR_NO_MOVE = -3, /* no legal move (mate/stalemate) */ + CHESS_ERR_BUFFER = -4, /* out_buf too small for the move + NUL */ + CHESS_ERR_INTERNAL = -5 /* unexpected engine failure */ +}; + +/* Create an engine instance. + * options: optional null-terminated UTF-8 config string (may be NULL), + * e.g. "skill=20;hash=256". Parse however you like; ignore for now. + * returns: a valid EngineHandle, or NULL on allocation failure. */ +CHESS_API EngineHandle CHESS_CALL engine_create(const char* options); + +/* Set a single option by name (optional; may no-op for now). + * returns CHESS_OK or a negative code. */ +CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine, + const char* name, + const char* value); + +/* 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. + * 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). + * returns CHESS_OK on success (out_buf now holds the move), else negative. + * 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, + char* out_buf, + int out_len); + +/* Write the engine version string into out_buf (null-terminated). + * returns CHESS_OK or CHESS_ERR_BUFFER. */ +CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len); + +/* Destroy an instance created by engine_create. Safe to call with NULL. */ +CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine); + +#ifdef __cplusplus +} +#endif +#endif /* CHESS_ENGINE_H */ diff --git a/native/chess_engine/src/bitboard.cpp b/native/chess_engine/src/bitboard.cpp new file mode 100644 index 0000000..2681695 --- /dev/null +++ b/native/chess_engine/src/bitboard.cpp @@ -0,0 +1,146 @@ +#include "bitboard.h" + +#include + +namespace chess { + +Bitboard PawnAttacks[COLOR_NB][SQUARE_NB]; +Bitboard KnightAttacks[SQUARE_NB]; +Bitboard KingAttacks[SQUARE_NB]; + +Magic BishopMagics[SQUARE_NB]; +Magic RookMagics[SQUARE_NB]; + +// Backing storage the magics index into (fancy-magic sizes). +static Bitboard RookTable[102400]; +static Bitboard BishopTable[5248]; + +namespace { + +int file_distance(Square a, Square b) { + return std::abs(int(file_of(a)) - int(file_of(b))); +} + +// Slow, edge-aware ray attack used only to build the tables. +Bitboard sliding_attack(const int* dirs, Square sq, Bitboard occ) { + Bitboard attacks = 0; + for (int i = 0; i < 4; ++i) { + Square prev = sq; + int t = int(sq) + dirs[i]; + while (t >= 0 && t < 64 && file_distance(Square(t), prev) <= 1) { + attacks |= square_bb(Square(t)); + if (occ & square_bb(Square(t))) break; + prev = Square(t); + t += dirs[i]; + } + } + return attacks; +} + +// Relevant-occupancy mask: the ray squares excluding board edges. +Bitboard slider_mask(const int* dirs, Square sq) { + Bitboard edges = ((RANK_1_BB | RANK_8_BB) & ~rank_bb(rank_of(sq))) + | ((FILE_A_BB | FILE_H_BB) & ~file_bb(file_of(sq))); + return sliding_attack(dirs, sq, 0) & ~edges; +} + +// Deterministic xorshift PRNG (fixed seed -> reproducible magics). +struct PRNG { + uint64_t s; + explicit PRNG(uint64_t seed) : s(seed) {} + uint64_t next() { + s ^= s >> 12; s ^= s << 25; s ^= s >> 27; + return s * 2685821657736338717ULL; + } + // Few set bits -> better magic candidates. + uint64_t sparse() { return next() & next() & next(); } +}; + +void init_magics(const int* dirs, Magic magics[], Bitboard table[]) { + PRNG rng(0x9E3779B97F4A7C15ull); // fixed seed -> reproducible magics + + Bitboard occupancy[4096]; + Bitboard reference[4096]; + int epoch[4096] = {}; + int currentEpoch = 0; + + size_t offset = 0; + for (int sq = 0; sq < 64; ++sq) { + Magic& m = magics[sq]; + m.mask = slider_mask(dirs, Square(sq)); + m.shift = 64 - popcount(m.mask); + m.attacks = table + offset; + + // Enumerate every subset of the mask (Carry-Rippler). + Bitboard b = 0; + int size = 0; + do { + occupancy[size] = b; + reference[size] = sliding_attack(dirs, Square(sq), b); + ++size; + b = (b - m.mask) & m.mask; + } while (b); + + // Search for a magic that maps subsets to indices collision-free + // (collisions are fine only when the attack set is identical). + for (;;) { + Bitboard magic; + do { + magic = rng.sparse(); + } while (popcount((m.mask * magic) >> 56) < 6); + + m.magic = magic; + ++currentEpoch; + bool ok = true; + for (int i = 0; i < size; ++i) { + unsigned idx = m.index(occupancy[i]); + if (epoch[idx] < currentEpoch) { + epoch[idx] = currentEpoch; + m.attacks[idx] = reference[i]; + } else if (m.attacks[idx] != reference[i]) { + ok = false; + break; + } + } + if (ok) break; + } + + offset += size; + } +} + +} // namespace + +void init_bitboards() { + for (int s = 0; s < 64; ++s) { + Bitboard b = square_bb(Square(s)); + + PawnAttacks[WHITE][s] = ((b & ~FILE_H_BB) << 9) | ((b & ~FILE_A_BB) << 7); + PawnAttacks[BLACK][s] = ((b & ~FILE_A_BB) >> 9) | ((b & ~FILE_H_BB) >> 7); + + const int knightDirs[8] = { 17, 15, 10, 6, -6, -10, -15, -17 }; + Bitboard kn = 0; + for (int d : knightDirs) { + int t = s + d; + if (t >= 0 && t < 64 && file_distance(Square(t), Square(s)) <= 2) + kn |= square_bb(Square(t)); + } + KnightAttacks[s] = kn; + + const int kingDirs[8] = { 8, -8, 1, -1, 9, 7, -7, -9 }; + Bitboard kg = 0; + for (int d : kingDirs) { + int t = s + d; + if (t >= 0 && t < 64 && file_distance(Square(t), Square(s)) <= 1) + kg |= square_bb(Square(t)); + } + KingAttacks[s] = kg; + } + + const int rookDirs[4] = { 8, -8, 1, -1 }; + const int bishopDirs[4] = { 9, 7, -7, -9 }; + init_magics(rookDirs, RookMagics, RookTable); + init_magics(bishopDirs, BishopMagics, BishopTable); +} + +} // namespace chess diff --git a/native/chess_engine/src/bitboard.h b/native/chess_engine/src/bitboard.h new file mode 100644 index 0000000..7b41652 --- /dev/null +++ b/native/chess_engine/src/bitboard.h @@ -0,0 +1,87 @@ +// Bitboard utilities and precomputed attack tables. Sliders use magic +// bitboards; the tables are built once by init_bitboards() (called from +// engine_create) and are read-only afterwards. +#ifndef CHESS_BITBOARD_H +#define CHESS_BITBOARD_H + +#include "types.h" + +#if defined(_MSC_VER) +#include +#endif + +namespace chess { + +constexpr Bitboard FILE_A_BB = 0x0101010101010101ULL; +constexpr Bitboard FILE_H_BB = 0x8080808080808080ULL; +constexpr Bitboard RANK_1_BB = 0x00000000000000FFULL; +constexpr Bitboard RANK_8_BB = 0xFF00000000000000ULL; + +inline Bitboard square_bb(Square s) { return 1ULL << s; } +inline Bitboard file_bb(File f) { return FILE_A_BB << f; } +inline Bitboard rank_bb(Rank r) { return RANK_1_BB << (8 * int(r)); } + +inline int popcount(Bitboard b) { +#if defined(_MSC_VER) + return int(__popcnt64(b)); +#else + return __builtin_popcountll(b); +#endif +} + +inline Square lsb(Bitboard b) { +#if defined(_MSC_VER) + unsigned long i; + _BitScanForward64(&i, b); + return Square(i); +#else + return Square(__builtin_ctzll(b)); +#endif +} + +// Returns the least-significant square and clears it from b. +inline Square pop_lsb(Bitboard& b) { + Square s = lsb(b); + b &= b - 1; + return s; +} + +inline bool more_than_one(Bitboard b) { return b & (b - 1); } + +// Precomputed leaper attacks (filled by init_bitboards). +extern Bitboard PawnAttacks[COLOR_NB][SQUARE_NB]; +extern Bitboard KnightAttacks[SQUARE_NB]; +extern Bitboard KingAttacks[SQUARE_NB]; + +struct Magic { + Bitboard mask; + Bitboard magic; + Bitboard* attacks; + unsigned shift; + + unsigned index(Bitboard occ) const { + return unsigned(((occ & mask) * magic) >> shift); + } +}; + +extern Magic BishopMagics[SQUARE_NB]; +extern Magic RookMagics[SQUARE_NB]; + +inline Bitboard bishop_attacks(Square s, Bitboard occ) { + const Magic& m = BishopMagics[s]; + return m.attacks[m.index(occ)]; +} +inline Bitboard rook_attacks(Square s, Bitboard occ) { + const Magic& m = RookMagics[s]; + return m.attacks[m.index(occ)]; +} +inline Bitboard queen_attacks(Square s, Bitboard occ) { + return bishop_attacks(s, occ) | rook_attacks(s, occ); +} + +// Must be called once before any attack query (engine_create does this). +void init_bitboards(); + +} // namespace chess + +#endif // CHESS_BITBOARD_H diff --git a/native/chess_engine/src/chess_engine.cpp b/native/chess_engine/src/chess_engine.cpp new file mode 100644 index 0000000..242f52a --- /dev/null +++ b/native/chess_engine/src/chess_engine.cpp @@ -0,0 +1,283 @@ +/* chess_engine.cpp - the DLL boundary (extern "C" ABI). + * + * The rules layer (board, move generation, make/unmake, hashing, perft) lives in + * the other src/*.cpp files and is ready to use. engine_best_move is intentionally + * left for YOU: that is where your search/evaluation goes. Everything below the + * FEN-in / UCI-out boundary should stay native — the managed side crosses it once + * per move. + */ +#ifndef CHESS_ENGINE_BUILD +#define CHESS_ENGINE_BUILD /* fallback when not building via CMake (which defines it) */ +#endif +#pragma once + +#include "chess_engine.h" +#include "bitboard.h" +#include "zobrist.h" +#include "position.h" +#include "movegen.h" +#include "uci.h" + +#include +#include +#include +#include +#include +#include + + +/* Internal engine state. Put your search tables, transposition table, etc. here. */ +struct ChessEngine { + int skill = 20; /* 1..20 from the UI; controls search depth */ +}; + +static int copy_out(const char* src, char* out_buf, int out_len) { + if (!out_buf || out_len <= 0) return CHESS_ERR_BUFFER; + const size_t need = std::strlen(src) + 1; /* + NUL */ + if (need > static_cast(out_len)) return CHESS_ERR_BUFFER; + std::memcpy(out_buf, src, need); + return CHESS_OK; +} + +/* Attack tables and Zobrist keys are global and read-only after this runs. */ +static void ensure_initialized() { + static bool done = false; + if (done) return; + chess::init_bitboards(); + chess::Zobrist::init(); + done = true; +} + +/* Pulls "skill=N" out of the engine_create options string; clamps to the UI's 1..20. */ +static int parse_skill(const char* options, int fallback) { + if (!options) return fallback; + const char* p = std::strstr(options, "skill="); + if (!p) return fallback; + int v = std::atoi(p + 6); + 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. */ +static int depth_for_skill(int skill) { + return skill; /* skill 1 -> 2 plies ... skill 20 -> 7 plies */ +} + +/* Positional multiplier in [0.5, 2.0] based on a square's distance from the four + * center squares (d4/e4/d5/e5): 2.0 dead center, 0.5 in a corner, scaling linearly. + * Multiply a piece's base value by this to reward central placement. */ +static double center_multiplier(chess::Square s) { + /* |2*coord - 7| is the distance from center in half-squares: 1 (center) .. 7 (edge). */ + int fileDist = std::abs(2 * int(chess::file_of(s)) - 7); + int rankDist = std::abs(2 * int(chess::rank_of(s)) - 7); + int dist = fileDist > rankDist ? fileDist : rankDist; /* Chebyshev distance, 1 .. 7 */ + + return dist * 20; /* 1 -> 2.0, 7 -> 0.5 */ +} + +static int piece_mobility(const chess::Position& pos, chess::Square s, chess::Piece pc, chess::Color c) { + chess::Bitboard occ = pos.pieces(); + chess::Bitboard targets; + + switch (chess::type_of(pc)) { + case chess::KNIGHT: targets = chess::KnightAttacks[s]; break; + case chess::BISHOP: targets = chess::bishop_attacks(s, occ); break; + case chess::ROOK: targets = chess::rook_attacks(s, occ); break; + case chess::QUEEN: targets = chess::queen_attacks(s, occ); break; + case chess::KING: targets = chess::KingAttacks[s]; break; + default: return 0; // pawns: mobility usually handled via push/attack separately + } + + return chess::popcount(targets & ~pos.pieces(c)); // exclude squares blocked by own pieces +} + +static chess::Bitboard front_span(chess::Color c, chess::Square s) { + chess::File f = file_of(s); + chess::Bitboard files = file_bb(f); + if (f > chess::FILE_A) files |= chess::file_bb(chess::File(f - 1)); + if (f < chess::FILE_H) files |= chess::file_bb(chess::File(f + 1)); + + // Pawns never sit on rank 1 or 8, so rank is 1..6 and these shifts + // are always in [8,56] — no shift-by-64 UB to guard against. + chess::Rank r = rank_of(s); + chess::Bitboard ahead = (c == chess::WHITE) ? (~0ULL << (8 * (r + 1))) // ranks > r + : ((1ULL << (8 * r)) - 1); // ranks < r + return files & ahead; +} + +static chess::Bitboard front_span_file_only(chess::Color c, chess::Square s) { + chess::File f = file_of(s); + chess::Bitboard files = file_bb(f); + + // Pawns never sit on rank 1 or 8, so rank is 1..6 and these shifts + // are always in [8,56] — no shift-by-64 UB to guard against. + chess::Rank r = rank_of(s); + chess::Bitboard ahead = (c == chess::WHITE) ? (~0ULL << (8 * (r + 1))) // ranks > r + : ((1ULL << (8 * r)) - 1); // ranks < r + return files & ahead; +} + +static int evaluatePawn(const chess::Position& pos, const chess::Color c, const chess::Square s) { + chess::Bitboard span = front_span(c, s); + chess::Bitboard file_span = front_span_file_only(c, s); + chess::Rank r = rank_of(s); + int squaresToPromotion = (c == chess::WHITE) ? (chess::RANK_8 - r) : (r - chess::RANK_1);; + bool isPassed = !(span & pos.pieces(~c, chess::PAWN)); + bool isBlocked = (file_span & pos.pieces(c, chess::PAWN)) | (file_span & pos.pieces(~c, chess::PAWN)); + bool isDoubled = (file_span & pos.pieces(c, chess::PAWN)); + + int score = 100; + + if (isPassed && !isBlocked) + score += squaresToPromotion * 10; // Bonus for passed pawns, more as they get closer to promotion + if (isDoubled) + score -= 20; // Penalty for doubled pawns + if (isBlocked) + score -= 20; // Penalty for blocked pawns + + return score; +} + +static int evaluatePiece(const chess::Position& pos, const chess::Square& s, const chess::Piece& pc, const chess::Color& c) { + int score = 0; + switch (chess::type_of(pc)) { + case chess::PAWN: score = evaluatePawn(pos, c, s); break; + case chess::KNIGHT: score = 320; break; + case chess::BISHOP: score = 330; break; + case chess::ROOK: score = 500; break; + case chess::QUEEN: score = 900; break; + default: return 0; + } + + score += center_multiplier(s); + score += piece_mobility(pos, s, pc, c) * 10; + + return score; +} + +static int evaluate(const chess::Position& pos) { + int score = 0; + chess::Bitboard white = pos.pieces(chess::WHITE); + + while (white) { + chess::Square s = chess::pop_lsb(white); + chess::Piece pc = pos.piece_on(s); + chess::Color c = chess::color_of(pc); + score += evaluatePiece(pos, s, pc, c); + } + + chess::Bitboard black = pos.pieces(chess::BLACK); + + while (black) { + chess::Square s = chess::pop_lsb(black); + chess::Piece pc = pos.piece_on(s); + chess::Color c = chess::color_of(pc); + score -= evaluatePiece(pos, s, pc, c); + } + + return score; +} + +extern "C" { + +CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) { + ensure_initialized(); + auto* e = new (std::nothrow) ChessEngine(); + if (!e) return nullptr; + e->skill = parse_skill(options, e->skill); + return e; +} + +CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine, + const char* /*name*/, + const char* /*value*/) { + if (!engine) return CHESS_ERR_NULL_HANDLE; + 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); + + chess::MoveList moves; + pos.generate_legal(moves); + + if (moves.size() == 0) + return pos.is_draw() ? 0 : whiteToMove ? -200000 + depth : 200000 - depth; + + 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; + } + + pos.undo_move(move); + } + + return whiteToMove ? bestForWhite : bestForBlack; +} + +CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine, + const char* fen, + char* out_buf, + int out_len) { + if (!engine) return CHESS_ERR_NULL_HANDLE; + if (!fen || !*fen) return CHESS_ERR_BAD_FEN; + + auto held = std::make_unique(chess::Position::from_fen(fen)); + chess::Position& pos = *held; + bool whiteToMove = pos.side_to_move() == chess::WHITE; + chess::MoveList moves; + pos.generate_legal(moves); + if (moves.size() == 0) + return CHESS_ERR_NO_MOVE; + + int maxDepth = depth_for_skill(engine->skill); + + int bestForWhite = std::numeric_limits::min(); + int bestForBlack = std::numeric_limits::max(); + chess::Move bestMove = moves.moves[0]; + + 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); + + if (whiteToMove && score > bestForWhite) { + bestForWhite = score; + bestMove = move; + } + else if (!whiteToMove && score < bestForBlack) { + bestForBlack = score; + bestMove = move; + } + } + + return copy_out(chess::move_to_uci(bestMove).c_str(), out_buf, out_len); +} + +CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len) { + return copy_out("custom-engine 0.1.0", out_buf, out_len); +} + +CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine) { + delete engine; /* delete nullptr is safe */ +} + +} /* extern "C" */ diff --git a/native/chess_engine/src/movegen.cpp b/native/chess_engine/src/movegen.cpp new file mode 100644 index 0000000..b2759ab --- /dev/null +++ b/native/chess_engine/src/movegen.cpp @@ -0,0 +1,134 @@ +#include "movegen.h" + +namespace chess { + +namespace { + +void add_promotions(MoveList& list, Square from, Square to) { + list.add(Move::make(from, to, PROMOTION, QUEEN)); + list.add(Move::make(from, to, PROMOTION, ROOK)); + list.add(Move::make(from, to, PROMOTION, BISHOP)); + list.add(Move::make(from, to, PROMOTION, KNIGHT)); +} + +void generate_castling(const Position& pos, MoveList& list) { + if (pos.in_check()) return; + + Color us = pos.side_to_move(), them = ~us; + Bitboard occ = pos.pieces(); + auto attacked = [&](Square sq) { return (pos.attackers_to(sq) & pos.pieces(them)) != 0; }; + + if (us == WHITE) { + if (pos.can_castle(WHITE, KINGSIDE) && + !(occ & (square_bb(F1) | square_bb(G1))) && !attacked(F1) && !attacked(G1)) + list.add(Move::make(E1, G1, CASTLING)); + if (pos.can_castle(WHITE, QUEENSIDE) && + !(occ & (square_bb(B1) | square_bb(C1) | square_bb(D1))) && !attacked(D1) && !attacked(C1)) + list.add(Move::make(E1, C1, CASTLING)); + } else { + if (pos.can_castle(BLACK, KINGSIDE) && + !(occ & (square_bb(F8) | square_bb(G8))) && !attacked(F8) && !attacked(G8)) + list.add(Move::make(E8, G8, CASTLING)); + if (pos.can_castle(BLACK, QUEENSIDE) && + !(occ & (square_bb(B8) | square_bb(C8) | square_bb(D8))) && !attacked(D8) && !attacked(C8)) + list.add(Move::make(E8, C8, CASTLING)); + } +} + +} // namespace + +void generate_pseudo(const Position& pos, MoveList& list) { + Color us = pos.side_to_move(), them = ~us; + Bitboard occ = pos.pieces(); + Bitboard targets = ~pos.pieces(us); // empty squares or enemy pieces + Bitboard theirs = pos.pieces(them); + + // Pawns + int push = (us == WHITE) ? 8 : -8; + Rank promoRank = (us == WHITE) ? RANK_8 : RANK_1; + Rank startRank = (us == WHITE) ? RANK_2 : RANK_7; + Bitboard b = pos.pieces(us, PAWN); + while (b) { + Square s = pop_lsb(b); + Square t = Square(int(s) + push); + if (!(occ & square_bb(t))) { + if (rank_of(t) == promoRank) { + add_promotions(list, s, t); + } else { + list.add(Move::make(s, t)); + if (rank_of(s) == startRank) { + Square t2 = Square(int(t) + push); + if (!(occ & square_bb(t2))) list.add(Move::make(s, t2)); + } + } + } + Bitboard caps = PawnAttacks[us][s] & theirs; + while (caps) { + Square c = pop_lsb(caps); + if (rank_of(c) == promoRank) add_promotions(list, s, c); + else list.add(Move::make(s, c)); + } + if (pos.ep_square() != SQ_NONE && (PawnAttacks[us][s] & square_bb(pos.ep_square()))) + list.add(Move::make(s, pos.ep_square(), EN_PASSANT)); + } + + // Knights + b = pos.pieces(us, KNIGHT); + while (b) { + Square s = pop_lsb(b); + Bitboard a = KnightAttacks[s] & targets; + while (a) list.add(Move::make(s, pop_lsb(a))); + } + + // Bishops + b = pos.pieces(us, BISHOP); + while (b) { + Square s = pop_lsb(b); + Bitboard a = bishop_attacks(s, occ) & targets; + while (a) list.add(Move::make(s, pop_lsb(a))); + } + + // Rooks + b = pos.pieces(us, ROOK); + while (b) { + Square s = pop_lsb(b); + Bitboard a = rook_attacks(s, occ) & targets; + while (a) list.add(Move::make(s, pop_lsb(a))); + } + + // Queens + b = pos.pieces(us, QUEEN); + while (b) { + Square s = pop_lsb(b); + Bitboard a = queen_attacks(s, occ) & targets; + while (a) list.add(Move::make(s, pop_lsb(a))); + } + + // King (non-castling) + { + Square s = pos.king_square(us); + Bitboard a = KingAttacks[s] & targets; + while (a) list.add(Move::make(s, pop_lsb(a))); + } +} + +void Position::generate_legal(MoveList& list) { + list.count = 0; + + MoveList pseudo; + generate_pseudo(*this, pseudo); + + Color us = sideToMove; + for (Move m : pseudo) { + do_move(m); + // After do_move, sideToMove is the opponent; the move is legal iff the + // side that just moved did not leave its own king attacked. + bool legal = (attackers_to(king_square(us)) & pieces(sideToMove)) == 0; + undo_move(m); + if (legal) list.add(m); + } + + generate_castling(*this, list); // already fully legal +} + +} // namespace chess diff --git a/native/chess_engine/src/movegen.h b/native/chess_engine/src/movegen.h new file mode 100644 index 0000000..33069eb --- /dev/null +++ b/native/chess_engine/src/movegen.h @@ -0,0 +1,17 @@ +// Legal move generation. generate_legal is a method on Position (declared +// there); this header exists so other translation units can pull in the +// pseudo-legal generator if they ever want it. +#ifndef CHESS_MOVEGEN_H +#define CHESS_MOVEGEN_H + +#include "position.h" + +namespace chess { + +// Generates pseudo-legal moves (ignores leaving your own king in check). +// Position::generate_legal filters these. Castling is generated fully-legal. +void generate_pseudo(const Position& pos, MoveList& list); + +} // namespace chess + +#endif // CHESS_MOVEGEN_H diff --git a/native/chess_engine/src/perft.cpp b/native/chess_engine/src/perft.cpp new file mode 100644 index 0000000..9386b00 --- /dev/null +++ b/native/chess_engine/src/perft.cpp @@ -0,0 +1,22 @@ +#include "perft.h" + +namespace chess { + +uint64_t perft(Position& pos, int depth) { + if (depth == 0) return 1; + + MoveList list; + pos.generate_legal(list); + + if (depth == 1) return uint64_t(list.size()); + + uint64_t nodes = 0; + for (Move m : list) { + pos.do_move(m); + nodes += perft(pos, depth - 1); + pos.undo_move(m); + } + return nodes; +} + +} // namespace chess diff --git a/native/chess_engine/src/perft.h b/native/chess_engine/src/perft.h new file mode 100644 index 0000000..da6f20b --- /dev/null +++ b/native/chess_engine/src/perft.h @@ -0,0 +1,14 @@ +// Perft: counts the leaf nodes of the legal move tree to a given depth. +// The standard correctness test for move generation + make/unmake. +#ifndef CHESS_PERFT_H +#define CHESS_PERFT_H + +#include "position.h" + +namespace chess { + +uint64_t perft(Position& pos, int depth); + +} // namespace chess + +#endif // CHESS_PERFT_H diff --git a/native/chess_engine/src/position.cpp b/native/chess_engine/src/position.cpp new file mode 100644 index 0000000..3330b05 --- /dev/null +++ b/native/chess_engine/src/position.cpp @@ -0,0 +1,347 @@ +#include "position.h" +#include "zobrist.h" + +#include +#include +#include +#include + + +namespace chess { + +namespace { + +// Bits of castling rights that are revoked when a piece leaves/arrives a square +// (covers king moves, rook moves, and rook captures uniformly). +int castling_mask(Square s) { + switch (s) { + case E1: return WHITE_OO | WHITE_OOO; + case A1: return WHITE_OOO; + case H1: return WHITE_OO; + case E8: return BLACK_OO | BLACK_OOO; + case A8: return BLACK_OOO; + case H8: return BLACK_OO; + default: return 0; + } +} + +char piece_to_char(Piece p) { + const char* w = " PNBRQK"; + char c = w[type_of(p)]; + return color_of(p) == BLACK ? char(std::tolower(c)) : c; +} + +} // namespace + +void Position::put_piece(Piece pc, Square s) { + board[s] = pc; + byTypeBB[type_of(pc)] |= square_bb(s); + byColorBB[color_of(pc)] |= square_bb(s); + zkey ^= Zobrist::psq[pc][s]; +} + +void Position::remove_piece(Square s) { + Piece pc = board[s]; + byTypeBB[type_of(pc)] ^= square_bb(s); + byColorBB[color_of(pc)] ^= square_bb(s); + board[s] = NO_PIECE; + zkey ^= Zobrist::psq[pc][s]; +} + +void Position::move_piece(Square from, Square to) { + Piece pc = board[from]; + Bitboard fromTo = square_bb(from) | square_bb(to); + byTypeBB[type_of(pc)] ^= fromTo; + byColorBB[color_of(pc)] ^= fromTo; + board[from] = NO_PIECE; + board[to] = pc; + zkey ^= Zobrist::psq[pc][from] ^ Zobrist::psq[pc][to]; +} + +bool Position::can_castle(Color c, CastlingSide side) const { + int r = (c == WHITE) ? (side == KINGSIDE ? WHITE_OO : WHITE_OOO) + : (side == KINGSIDE ? BLACK_OO : BLACK_OOO); + return (castlingRights & r) != 0; +} + +Position Position::from_fen(std::string_view fen) { + auto held = std::make_unique(); + Position& p = *held; + std::memset(p.byTypeBB, 0, sizeof(p.byTypeBB)); + std::memset(p.byColorBB, 0, sizeof(p.byColorBB)); + for (int s = 0; s < SQUARE_NB; ++s) p.board[s] = NO_PIECE; + p.sideToMove = WHITE; + p.castlingRights = NO_CASTLING; + p.epSquare = SQ_NONE; + p.rule50 = 0; + p.gamePly = 0; + p.zkey = 0; + p.undoCount = 0; + + size_t i = 0; + int rank = 7, file = 0; + + // 1) piece placement + for (; i < fen.size() && fen[i] != ' '; ++i) { + char c = fen[i]; + if (c == '/') { --rank; file = 0; } + else if (std::isdigit((unsigned char)c)) { file += c - '0'; } + else { + Color col = std::isupper((unsigned char)c) ? WHITE : BLACK; + PieceType pt = NO_PIECE_TYPE; + switch (std::tolower((unsigned char)c)) { + case 'p': pt = PAWN; break; + case 'n': pt = KNIGHT; break; + case 'b': pt = BISHOP; break; + case 'r': pt = ROOK; break; + case 'q': pt = QUEEN; break; + case 'k': pt = KING; break; + } + if (pt != NO_PIECE_TYPE) + p.put_piece(make_piece(col, pt), make_square(File(file), Rank(rank))); + ++file; + } + } + + auto skip_space = [&] { while (i < fen.size() && fen[i] == ' ') ++i; }; + + // 2) side to move + skip_space(); + if (i < fen.size()) { p.sideToMove = (fen[i] == 'b') ? BLACK : WHITE; ++i; } + + // 3) castling rights + skip_space(); + for (; i < fen.size() && fen[i] != ' '; ++i) { + switch (fen[i]) { + case 'K': p.castlingRights |= WHITE_OO; break; + case 'Q': p.castlingRights |= WHITE_OOO; break; + case 'k': p.castlingRights |= BLACK_OO; break; + case 'q': p.castlingRights |= BLACK_OOO; break; + default: break; // '-' or Chess960 letters + } + } + + // 4) en passant + skip_space(); + if (i < fen.size() && fen[i] != '-' && fen[i] != ' ') { + File f = File(fen[i] - 'a'); + Rank r = Rank(fen[i + 1] - '1'); + p.epSquare = make_square(f, r); + i += 2; + } else if (i < fen.size() && fen[i] == '-') { + ++i; + } + + // 5) halfmove clock + skip_space(); + int halfmove = 0; + for (; i < fen.size() && std::isdigit((unsigned char)fen[i]); ++i) + halfmove = halfmove * 10 + (fen[i] - '0'); + p.rule50 = halfmove; + + // 6) fullmove number + skip_space(); + int fullmove = 1; + if (i < fen.size() && std::isdigit((unsigned char)fen[i])) { + fullmove = 0; + for (; i < fen.size() && std::isdigit((unsigned char)fen[i]); ++i) + fullmove = fullmove * 10 + (fen[i] - '0'); + } + p.gamePly = (fullmove - 1) * 2 + (p.sideToMove == BLACK ? 1 : 0); + + // finalize the hash + if (p.sideToMove == BLACK) p.zkey ^= Zobrist::side; + p.zkey ^= Zobrist::castling[p.castlingRights]; + if (p.epSquare != SQ_NONE) p.zkey ^= Zobrist::enpassant[file_of(p.epSquare)]; + + p.repKeys[0] = p.zkey; + p.repCount = 1; + return p; +} + +std::string Position::to_fen() const { + std::string s; + for (int r = 7; r >= 0; --r) { + int empty = 0; + for (int f = 0; f < 8; ++f) { + Piece pc = board[make_square(File(f), Rank(r))]; + if (pc == NO_PIECE) { ++empty; continue; } + if (empty) { s += char('0' + empty); empty = 0; } + s += piece_to_char(pc); + } + if (empty) s += char('0' + empty); + if (r) s += '/'; + } + s += sideToMove == WHITE ? " w " : " b "; + + std::string cr; + if (castlingRights & WHITE_OO) cr += 'K'; + if (castlingRights & WHITE_OOO) cr += 'Q'; + if (castlingRights & BLACK_OO) cr += 'k'; + if (castlingRights & BLACK_OOO) cr += 'q'; + s += cr.empty() ? "-" : cr; + + s += ' '; + if (epSquare == SQ_NONE) s += '-'; + else { s += char('a' + file_of(epSquare)); s += char('1' + rank_of(epSquare)); } + + s += ' '; + s += std::to_string(rule50); + s += ' '; + s += std::to_string(fullmove_number()); + return s; +} + +Bitboard Position::attackers_to(Square s, Bitboard occ) const { + return (PawnAttacks[BLACK][s] & pieces(WHITE, PAWN)) + | (PawnAttacks[WHITE][s] & pieces(BLACK, PAWN)) + | (KnightAttacks[s] & byTypeBB[KNIGHT]) + | (KingAttacks[s] & byTypeBB[KING]) + | (bishop_attacks(s, occ) & (byTypeBB[BISHOP] | byTypeBB[QUEEN])) + | (rook_attacks(s, occ) & (byTypeBB[ROOK] | byTypeBB[QUEEN])); +} + +bool Position::in_check() const { + return (attackers_to(king_square(sideToMove)) & pieces(~sideToMove)) != 0; +} + +bool Position::gives_check(Move m) { + do_move(m); + bool checked = in_check(); + undo_move(m); + return checked; +} + +void Position::do_move(Move m) { + Color us = sideToMove, them = ~us; + Square from = m.from(), to = m.to(); + MoveFlag flag = m.type(); + Piece pc = board[from]; + Piece captured = (flag == EN_PASSANT) ? make_piece(them, PAWN) : board[to]; + + Undo& u = undoStack[undoCount++]; + u.castlingRights = castlingRights; + u.epSquare = epSquare; + u.rule50 = rule50; + u.key = zkey; + u.captured = captured; + + if (epSquare != SQ_NONE) { + zkey ^= Zobrist::enpassant[file_of(epSquare)]; + epSquare = SQ_NONE; + } + + ++rule50; + + if (captured != NO_PIECE) { + Square capsq = to; + if (flag == EN_PASSANT) capsq = (us == WHITE) ? Square(to - 8) : Square(to + 8); + remove_piece(capsq); + rule50 = 0; + } + + move_piece(from, to); + + if (type_of(pc) == PAWN) { + rule50 = 0; + if ((int(to) ^ int(from)) == 16) { + epSquare = Square((from + to) / 2); + zkey ^= Zobrist::enpassant[file_of(epSquare)]; + } else if (flag == PROMOTION) { + remove_piece(to); + put_piece(make_piece(us, m.promotion()), to); + } + } + + if (flag == CASTLING) { + Square rookFrom, rookTo; + if (to > from) { rookFrom = Square(from + 3); rookTo = Square(from + 1); } + else { rookFrom = Square(from - 4); rookTo = Square(from - 1); } + move_piece(rookFrom, rookTo); + } + + int cr = castlingRights & ~(castling_mask(from) | castling_mask(to)); + if (cr != castlingRights) { + zkey ^= Zobrist::castling[castlingRights]; + zkey ^= Zobrist::castling[cr]; + castlingRights = cr; + } + + sideToMove = them; + zkey ^= Zobrist::side; + ++gamePly; + + repKeys[repCount++] = zkey; +} + +void Position::undo_move(Move m) { + Color us = ~sideToMove; + Square from = m.from(), to = m.to(); + MoveFlag flag = m.type(); + Undo u = undoStack[--undoCount]; + + if (flag == PROMOTION) { + remove_piece(to); + put_piece(make_piece(us, PAWN), to); + } + + move_piece(to, from); + + if (u.captured != NO_PIECE) { + Square capsq = to; + if (flag == EN_PASSANT) capsq = (us == WHITE) ? Square(to - 8) : Square(to + 8); + put_piece(u.captured, capsq); + } + + if (flag == CASTLING) { + Square rookFrom, rookTo; + if (to > from) { rookFrom = Square(from + 3); rookTo = Square(from + 1); } + else { rookFrom = Square(from - 4); rookTo = Square(from - 1); } + move_piece(rookTo, rookFrom); + } + + sideToMove = us; + castlingRights = u.castlingRights; + epSquare = u.epSquare; + rule50 = u.rule50; + zkey = u.key; + --gamePly; + --repCount; +} + +bool Position::insufficient_material() const { + if (byTypeBB[PAWN] | byTypeBB[ROOK] | byTypeBB[QUEEN]) + return false; + int minors = popcount(byTypeBB[KNIGHT] | byTypeBB[BISHOP]); + return minors <= 1; // KvK, KvKN, KvKB +} + +bool Position::is_draw() const { + if (rule50 >= 100) return true; + if (insufficient_material()) return true; + + uint64_t k = repKeys[repCount - 1]; + int seen = 0; + for (int i = repCount - 3; i >= 0 && i >= repCount - 1 - rule50; i -= 2) + if (repKeys[i] == k && ++seen >= 2) + return true; // threefold + return false; +} + +void Position::print() const { + std::printf("\n +---+---+---+---+---+---+---+---+\n"); + for (int r = 7; r >= 0; --r) { + std::printf("%d ", r + 1); + for (int f = 0; f < 8; ++f) { + Piece pc = board[make_square(File(f), Rank(r))]; + std::printf("| %c ", pc == NO_PIECE ? ' ' : piece_to_char(pc)); + } + std::printf("|\n +---+---+---+---+---+---+---+---+\n"); + } + std::printf(" a b c d e f g h\n"); + std::printf(" %s to move key=%016llx\n", + sideToMove == WHITE ? "White" : "Black", + (unsigned long long)zkey); +} + +} // namespace chess diff --git a/native/chess_engine/src/position.h b/native/chess_engine/src/position.h new file mode 100644 index 0000000..cc9e99e --- /dev/null +++ b/native/chess_engine/src/position.h @@ -0,0 +1,87 @@ +// The board. Hybrid representation: bitboards (per piece type and per color) +// for fast generation/attacks, plus a piece-on-square mailbox for O(1) +// "what's here?" queries. do_move/undo_move keep both in sync, along with the +// Zobrist key. One Position is one game line; it is freely copyable. +#ifndef CHESS_POSITION_H +#define CHESS_POSITION_H + +#include "types.h" +#include "bitboard.h" + +#include +#include + +namespace chess { + +class Position { +public: + /// Parse a FEN string into a position. + static Position from_fen(std::string_view fen); + /// Serialize back to FEN. + std::string to_fen() const; + + // --- mailbox queries --- + Piece piece_on(Square s) const { return board[s]; } + bool empty(Square s) const { return board[s] == NO_PIECE; } + Color side_to_move() const { return sideToMove; } + Square ep_square() const { return epSquare; } + int halfmove_clock() const { return rule50; } + int fullmove_number() const { return 1 + gamePly / 2; } + bool can_castle(Color c, CastlingSide side) const; + Square king_square(Color c) const { return lsb(pieces(c, KING)); } + + // --- bitboard accessors --- + Bitboard pieces() const { return byColorBB[WHITE] | byColorBB[BLACK]; } + Bitboard pieces(Color c) const { return byColorBB[c]; } + Bitboard pieces(PieceType pt) const { return byTypeBB[pt]; } + Bitboard pieces(Color c, PieceType pt) const { return byTypeBB[pt] & byColorBB[c]; } + + // --- attacks / checks --- + Bitboard attackers_to(Square s) const { return attackers_to(s, pieces()); } + Bitboard attackers_to(Square s, Bitboard occ) const; + bool in_check() const; // is side_to_move in check? + bool gives_check(Move m); // does m check the opponent? + + // --- the three you asked for --- + void generate_legal(MoveList& list); // defined in movegen.cpp + void do_move(Move m); + void undo_move(Move m); + + // --- freebies --- + uint64_t key() const { return zkey; } + bool is_draw() const; // 50-move + threefold + insufficient material + void print() const; + +private: + void put_piece(Piece pc, Square s); + void remove_piece(Square s); + void move_piece(Square from, Square to); + bool insufficient_material() const; + + Bitboard byTypeBB[PIECE_TYPE_NB]; + Bitboard byColorBB[COLOR_NB]; + Piece board[SQUARE_NB]; + Color sideToMove; + int castlingRights; + Square epSquare; + int rule50; + int gamePly; + uint64_t zkey; + + struct Undo { + int castlingRights; + Square epSquare; + int rule50; + uint64_t key; + Piece captured; + }; + Undo undoStack[1024]; + int undoCount; + + uint64_t repKeys[1024]; + int repCount; +}; + +} // namespace chess + +#endif // CHESS_POSITION_H diff --git a/native/chess_engine/src/types.h b/native/chess_engine/src/types.h new file mode 100644 index 0000000..f16e41c --- /dev/null +++ b/native/chess_engine/src/types.h @@ -0,0 +1,107 @@ +// Core vocabulary for the chess engine: squares, pieces, moves. +// Everything else is built on these. Convention: A1 = 0 ... H8 = 63, +// file = square & 7 (A..H), rank = square >> 3 (1..8). North = +8. +#ifndef CHESS_TYPES_H +#define CHESS_TYPES_H + +#include + +namespace chess { + +using Bitboard = uint64_t; + +enum Color : int { WHITE, BLACK, COLOR_NB = 2 }; + +enum PieceType : int { + NO_PIECE_TYPE, PAWN, KNIGHT, BISHOP, ROOK, QUEEN, KING, PIECE_TYPE_NB = 8 +}; + +enum Piece : int { + NO_PIECE, + W_PAWN = PAWN, W_KNIGHT, W_BISHOP, W_ROOK, W_QUEEN, W_KING, + B_PAWN = PAWN + 8, B_KNIGHT, B_BISHOP, B_ROOK, B_QUEEN, B_KING, + PIECE_NB = 16 +}; + +enum Square : int { + A1, B1, C1, D1, E1, F1, G1, H1, + A2, B2, C2, D2, E2, F2, G2, H2, + A3, B3, C3, D3, E3, F3, G3, H3, + A4, B4, C4, D4, E4, F4, G4, H4, + A5, B5, C5, D5, E5, F5, G5, H5, + A6, B6, C6, D6, E6, F6, G6, H6, + A7, B7, C7, D7, E7, F7, G7, H7, + A8, B8, C8, D8, E8, F8, G8, H8, + SQ_NONE, + SQUARE_NB = 64 +}; + +enum File : int { FILE_A, FILE_B, FILE_C, FILE_D, FILE_E, FILE_F, FILE_G, FILE_H, FILE_NB = 8 }; +enum Rank : int { RANK_1, RANK_2, RANK_3, RANK_4, RANK_5, RANK_6, RANK_7, RANK_8, RANK_NB = 8 }; + +enum CastlingSide : int { KINGSIDE, QUEENSIDE }; + +// Castling rights as a bitmask. +enum CastlingRights : int { + NO_CASTLING = 0, + WHITE_OO = 1, WHITE_OOO = 2, + BLACK_OO = 4, BLACK_OOO = 8, + ANY_CASTLING = 15 +}; + +constexpr Color operator~(Color c) { return Color(c ^ BLACK); } + +constexpr Square make_square(File f, Rank r) { return Square((r << 3) + f); } +constexpr File file_of(Square s) { return File(s & 7); } +constexpr Rank rank_of(Square s) { return Rank(s >> 3); } + +constexpr Piece make_piece(Color c, PieceType pt) { return Piece((c << 3) + pt); } +constexpr PieceType type_of(Piece p) { return PieceType(p & 7); } +constexpr Color color_of(Piece p) { return Color(p >> 3); } // assumes p != NO_PIECE + +// A move packed into 16 bits: from:6 | to:6 | promotion:2 | flag:2. +// The promotion bits encode KNIGHT..QUEEN as 0..3 and are only meaningful +// when the flag is PROMOTION. +enum MoveFlag : int { NORMAL, PROMOTION, EN_PASSANT, CASTLING }; + +struct Move { + uint16_t data; + + constexpr Move() : data(0) {} + constexpr explicit Move(uint16_t d) : data(d) {} + + /// Build a move. `promo` only matters when `flag == PROMOTION`. + static constexpr Move make(Square from, Square to, MoveFlag flag = NORMAL, + PieceType promo = KNIGHT) { + return Move(uint16_t((flag << 14) | ((promo - KNIGHT) << 12) | (to << 6) | from)); + } + + constexpr Square from() const { return Square(data & 0x3F); } + constexpr Square to() const { return Square((data >> 6) & 0x3F); } + constexpr MoveFlag type() const { return MoveFlag((data >> 14) & 0x3); } + constexpr PieceType promotion() const { return PieceType(((data >> 12) & 0x3) + KNIGHT); } + + constexpr bool operator==(Move m) const { return data == m.data; } + constexpr bool operator!=(Move m) const { return data != m.data; } +}; + +// A1->A1 is never a real move, so an all-zero move is our "none" sentinel. +constexpr Move MOVE_NONE = Move(0); + +// Fixed-capacity, allocation-free, range-for friendly. 256 covers any legal position. +struct MoveList { + Move moves[256]; + int count = 0; + + void add(Move m) { moves[count++] = m; } + int size() const { return count; } + + Move* begin() { return moves; } + Move* end() { return moves + count; } + const Move* begin() const { return moves; } + const Move* end() const { return moves + count; } +}; + +} // namespace chess + +#endif // CHESS_TYPES_H diff --git a/native/chess_engine/src/uci.cpp b/native/chess_engine/src/uci.cpp new file mode 100644 index 0000000..3871e57 --- /dev/null +++ b/native/chess_engine/src/uci.cpp @@ -0,0 +1,50 @@ +#include "uci.h" + +#include + +namespace chess { + +std::string move_to_uci(Move m) { + if (m == MOVE_NONE) return "0000"; + + Square f = m.from(), t = m.to(); + std::string s; + s += char('a' + file_of(f)); + s += char('1' + rank_of(f)); + s += char('a' + file_of(t)); + s += char('1' + rank_of(t)); + + if (m.type() == PROMOTION) { + static const char promo[PIECE_TYPE_NB] = { 0, 0, 'n', 'b', 'r', 'q', 0 }; + s += promo[m.promotion()]; + } + return s; +} + +Move move_from_uci(const Position& pos, std::string_view uci) { + if (uci.size() < 4) return MOVE_NONE; + + Square from = make_square(File(uci[0] - 'a'), Rank(uci[1] - '1')); + Square to = make_square(File(uci[2] - 'a'), Rank(uci[3] - '1')); + + if (uci.size() >= 5) { + PieceType promo = QUEEN; + switch (uci[4]) { + case 'q': promo = QUEEN; break; + case 'r': promo = ROOK; break; + case 'b': promo = BISHOP; break; + case 'n': promo = KNIGHT; break; + } + return Move::make(from, to, PROMOTION, promo); + } + + Piece pc = pos.piece_on(from); + if (type_of(pc) == KING && std::abs(int(to) - int(from)) == 2) + return Move::make(from, to, CASTLING); + if (type_of(pc) == PAWN && to == pos.ep_square() && file_of(from) != file_of(to)) + return Move::make(from, to, EN_PASSANT); + + return Move::make(from, to); +} + +} // namespace chess diff --git a/native/chess_engine/src/uci.h b/native/chess_engine/src/uci.h new file mode 100644 index 0000000..3baa976 --- /dev/null +++ b/native/chess_engine/src/uci.h @@ -0,0 +1,19 @@ +// Conversions between moves and UCI long-algebraic strings ("e2e4", "e7e8q"). +// move_from_uci resolves the move's flag (castling / en passant / promotion) +// against the given position. +#ifndef CHESS_UCI_H +#define CHESS_UCI_H + +#include "position.h" + +#include +#include + +namespace chess { + +std::string move_to_uci(Move m); +Move move_from_uci(const Position& pos, std::string_view uci); + +} // namespace chess + +#endif // CHESS_UCI_H diff --git a/native/chess_engine/src/zobrist.cpp b/native/chess_engine/src/zobrist.cpp new file mode 100644 index 0000000..da832bf --- /dev/null +++ b/native/chess_engine/src/zobrist.cpp @@ -0,0 +1,39 @@ +#include "zobrist.h" + +namespace chess { +namespace Zobrist { + +uint64_t psq[PIECE_NB][SQUARE_NB]; +uint64_t enpassant[FILE_NB]; +uint64_t castling[16]; +uint64_t side; + +namespace { +struct PRNG { + uint64_t s; + explicit PRNG(uint64_t seed) : s(seed) {} + uint64_t next() { + s ^= s >> 12; s ^= s << 25; s ^= s >> 27; + return s * 2685821657736338717ULL; + } +}; +} + +void init() { + PRNG rng(0xC0FFEE123456789Aull); + + for (int p = 0; p < PIECE_NB; ++p) + for (int s = 0; s < SQUARE_NB; ++s) + psq[p][s] = rng.next(); + + for (int f = 0; f < FILE_NB; ++f) + enpassant[f] = rng.next(); + + for (int c = 0; c < 16; ++c) + castling[c] = rng.next(); + + side = rng.next(); +} + +} // namespace Zobrist +} // namespace chess diff --git a/native/chess_engine/src/zobrist.h b/native/chess_engine/src/zobrist.h new file mode 100644 index 0000000..79751c5 --- /dev/null +++ b/native/chess_engine/src/zobrist.h @@ -0,0 +1,21 @@ +// Zobrist hashing keys. Filled once by Zobrist::init() (from engine_create). +// Position maintains the running key incrementally in do_move/undo_move. +#ifndef CHESS_ZOBRIST_H +#define CHESS_ZOBRIST_H + +#include "types.h" + +namespace chess { +namespace Zobrist { + +extern uint64_t psq[PIECE_NB][SQUARE_NB]; +extern uint64_t enpassant[FILE_NB]; +extern uint64_t castling[16]; +extern uint64_t side; + +void init(); + +} // namespace Zobrist +} // namespace chess + +#endif // CHESS_ZOBRIST_H diff --git a/native/chess_engine/test/perft_main.cpp b/native/chess_engine/test/perft_main.cpp new file mode 100644 index 0000000..f968095 --- /dev/null +++ b/native/chess_engine/test/perft_main.cpp @@ -0,0 +1,76 @@ +// Standalone perft harness: validates move generation + make/unmake against +// published node counts. Build separately from the DLL (see build instructions +// in the repo); not part of the shipped library. +#include "../src/bitboard.h" +#include "../src/zobrist.h" +#include "../src/position.h" +#include "../src/perft.h" + +#include +#include + +using namespace chess; + +struct Case { + const char* name; + const char* fen; + int depth; + uint64_t expected; +}; + +// Verifies the incrementally-maintained key matches a from-scratch hash of the +// same position (also exercises to_fen -> from_fen round-tripping). +static uint64_t verify_keys(Position& pos, int depth) { + uint64_t mismatches = 0; + if (pos.key() != Position::from_fen(pos.to_fen()).key()) + ++mismatches; + if (depth == 0) return mismatches; + + MoveList list; + pos.generate_legal(list); + for (Move m : list) { + pos.do_move(m); + mismatches += verify_keys(pos, depth - 1); + pos.undo_move(m); + } + return mismatches; +} + +int main() { + init_bitboards(); + Zobrist::init(); + + const Case cases[] = { + {"startpos d5", "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", 5, 4865609ULL}, + {"kiwipete d4", "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", 4, 4085603ULL}, + {"position3 d5", "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", 5, 674624ULL}, + {"position4 d4", "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1", 4, 422333ULL}, + {"position5 d4", "rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8", 4, 2103487ULL}, + {"position6 d4", "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10", 4, 3894594ULL}, + }; + + int fails = 0; + for (const Case& c : cases) { + Position pos = Position::from_fen(c.fen); + uint64_t got = perft(pos, c.depth); + bool ok = (got == c.expected); + std::printf("%-14s %14llu expected %14llu %s\n", + c.name, (unsigned long long)got, (unsigned long long)c.expected, + ok ? "OK" : "FAIL"); + if (!ok) ++fails; + } + + std::printf("\n%s\n", fails ? "*** PERFT FAILED ***" : "ALL PERFT PASSED"); + + // Zobrist key + FEN round-trip consistency. + const char* startfen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; + const char* kiwifen = "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1"; + Position a = Position::from_fen(startfen); + Position b = Position::from_fen(kiwifen); + uint64_t km = verify_keys(a, 4) + verify_keys(b, 3); + std::printf("key/fen mismatches: %llu %s\n", (unsigned long long)km, + km == 0 ? "OK" : "FAIL"); + if (km) ++fails; + + return fails ? 1 : 0; +} From fc2a2e122b2c9d34ab602cec8e7bd1bf7e6d5e0d Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Mon, 8 Jun 2026 14:12:50 -0600 Subject: [PATCH 2/2] Build libchess_engine.so in CI deploy and pin runner to ubuntu-24.04 Compile the native engine and copy it into Resources/ before publish so it ships to prod automatically. Runner pinned to ubuntu-24.04 to match the server's glibc. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 26c0e01..49a282e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,7 +14,9 @@ concurrency: jobs: build: - runs-on: ubuntu-latest + # Pinned (not ubuntu-latest) to match the prod server's Ubuntu/glibc so the + # compiled libchess_engine.so loads there. Keep this == the server's release. + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v4 @@ -25,6 +27,13 @@ jobs: dotnet-version: '8.0.x' # adjust if needed - name: Restore run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj + + - name: Build native chess engine (libchess_engine.so) + run: | + cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release + cmake --build native/chess_engine/build + cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/ + - name: Publish run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish