Gitea/workflows #1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
+4
-1
@@ -360,4 +360,7 @@ MigrationBackup/
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
FodyWeavers.xsd
|
||||
|
||||
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
|
||||
native/**/build/
|
||||
+36
-2
@@ -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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"solution": {
|
||||
"path": "JoshHeaps.Net.sln",
|
||||
"projects": [
|
||||
"JoshHeaps.Net\\JoshHeaps.Net.csproj",
|
||||
"JoshHeaps.Net.UiTests\\JoshHeaps.Net.UiTests.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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> chessHub,
|
||||
IBackgroundTaskQueue queue) : ControllerBase
|
||||
IBackgroundTaskQueue queue,
|
||||
IChessEngineFactory engineFactory,
|
||||
IComputerMoveOrchestrator orchestrator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 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);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>53ed685c-bdff-4306-8cc2-9fbe55c85713</UserSecretsId>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -28,4 +29,16 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Builds the native engine and refreshes Resources\chess_engine.dll (via its post-build copy)
|
||||
only inside Visual Studio, which has the C++ tools. The dotnet CLI can't build a .vcxproj,
|
||||
so this is skipped by `dotnet build`/`dotnet publish` (incl. the Linux CI, which deploys the
|
||||
libchess_engine.so instead). ReferenceOutputAssembly=false: build-ordering only, not a
|
||||
managed reference to the native DLL. -->
|
||||
<ItemGroup Condition="'$(BuildingInsideVisualStudio)' == 'true'">
|
||||
<ProjectReference Include="..\native\chess_engine\chess_engine\chess_engine.vcxproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<Private>false</Private>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,6 +22,10 @@ builder.Services.AddSingleton<IBlogService, BlogService>();
|
||||
builder.Services.AddSingleton<IChessService, ChessService>();
|
||||
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
|
||||
|
||||
builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEngineOptions.SectionName));
|
||||
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
|
||||
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
|
||||
|
||||
if (!builder.Environment.IsDevelopment())
|
||||
builder.Services.AddHostedService<AutoIpUpdateService>();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,32 @@
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
/// <summary>The available chess engine implementations.</summary>
|
||||
public enum ChessEngineKind
|
||||
{
|
||||
Stockfish,
|
||||
Custom
|
||||
}
|
||||
|
||||
/// <summary>Configuration selecting which <see cref="IChessEngine"/> to use.</summary>
|
||||
public sealed class ChessEngineOptions
|
||||
{
|
||||
public const string SectionName = "ChessEngine";
|
||||
|
||||
public ChessEngineKind Engine { get; set; } = ChessEngineKind.Stockfish;
|
||||
}
|
||||
|
||||
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
|
||||
public sealed class ChessEngineFactory(IOptions<ChessEngineOptions> 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}'.")
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
|
||||
/// Only piece placement + active colour + castling are computed; the rest use
|
||||
/// safe defaults (-, 0, 1). That is all the engine needs.
|
||||
/// </summary>
|
||||
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))
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class ComputerMoveOrchestrator(
|
||||
IHubContext<ChessHub> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Middleman wrapper over the native custom chess engine (chess_engine.dll / libchess_engine.so).
|
||||
/// Shares <see cref="IChessEngine"/> with <see cref="Stockfish"/> so the two are swappable.
|
||||
/// The boundary contract is FEN string in, UCI move string out — identical to Stockfish.
|
||||
/// </summary>
|
||||
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<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>Guarantees the native handle is released exactly once via engine_destroy.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<string> _stdout = Channel.CreateUnbounded<string>();
|
||||
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> chessHub, IChessService chessService)
|
||||
{
|
||||
var move = await GetBestMoveAsync(state.ToFen());
|
||||
|
||||
var moveDto = move.ToMoveDto(
|
||||
state,
|
||||
state.CurrentPlayer == PieceColor.White
|
||||
? state.WhitePlayerId
|
||||
: state.BlackPlayerId);
|
||||
|
||||
var result = chessService.MakeMove(state, moveDto);
|
||||
|
||||
await chessHub.Clients.Group(state.GameId.ToString()).SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), moveDto, result);
|
||||
}
|
||||
}
|
||||
|
||||
public static class StockfishHelpers
|
||||
{
|
||||
public static MoveDto ToMoveDto(
|
||||
this string uci,
|
||||
GameState gameState,
|
||||
Guid playerId)
|
||||
{
|
||||
int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1');
|
||||
int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1');
|
||||
|
||||
var piece = gameState.Board[fRow, fCol]
|
||||
?? throw new Exception("No piece at source square");
|
||||
|
||||
PieceType? promo = uci.Length == 5 ? uci[4] switch
|
||||
{
|
||||
'q' => PieceType.Queen,
|
||||
'r' => PieceType.Rook,
|
||||
'b' => PieceType.Bishop,
|
||||
'n' => PieceType.Knight,
|
||||
_ => null
|
||||
} : null;
|
||||
|
||||
return new MoveDto
|
||||
{
|
||||
GameId = gameState.GameId,
|
||||
PlayerId = playerId,
|
||||
PieceId = piece.Id,
|
||||
TargetRow = tRow,
|
||||
TargetCol = tCol,
|
||||
PromotionChoice = promo,
|
||||
SourceCol = fCol,
|
||||
SourceRow = fRow,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
|
||||
/// Only piece placement + active colour + castling are computed; the rest use
|
||||
/// safe defaults (-, 0, 1). That is all Stockfish needs.
|
||||
/// </summary>
|
||||
public static string ToFen(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))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="IAsyncDisposable"/>.
|
||||
/// </summary>
|
||||
public interface IChessEngine : IAsyncDisposable
|
||||
{
|
||||
/// <summary>The engine's playing strength / search depth.</summary>
|
||||
int Skill { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the engine's chosen move in UCI long-algebraic form (e.g. "e2e4", "e7e8q").
|
||||
/// </summary>
|
||||
/// <param name="fen">The current position as a FEN string.</param>
|
||||
/// <returns>The selected move as a UCI string.</returns>
|
||||
Task<string> GetBestMoveAsync(string fen);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Creates <see cref="IChessEngine"/> instances, choosing the implementation from configuration.
|
||||
/// </summary>
|
||||
public interface IChessEngineFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new engine instance for a single game. The caller owns and disposes it.
|
||||
/// </summary>
|
||||
/// <param name="skill">The desired playing strength / search depth.</param>
|
||||
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
|
||||
IChessEngine Create(int skill);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IComputerMoveOrchestrator
|
||||
{
|
||||
/// <summary>
|
||||
/// Has the engine pick a move for the current position, applies it, and broadcasts it.
|
||||
/// </summary>
|
||||
/// <param name="state">The game to play a move in.</param>
|
||||
/// <param name="engine">The engine that selects the move.</param>
|
||||
/// <returns>The applied move and its result.</returns>
|
||||
Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine);
|
||||
}
|
||||
@@ -5,5 +5,8 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ChessEngine": {
|
||||
"Engine": "Custom"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,8 @@
|
||||
"BlogApi": {
|
||||
"BaseUrl": "https://media.joshheaps.net",
|
||||
"InvalidateKey": "CHANGE_ME"
|
||||
},
|
||||
"ChessEngine": {
|
||||
"Engine": "Custom"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<br/>engine_best_move(fen, out)"]
|
||||
ABI --> SEARCH["self-contained search<br/>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<ChessHub>, 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 { <<interface>> }
|
||||
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<string> 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<string> 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<ChessEngineOptions> 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<ChessEngineOptions>(configuration.GetSection(ChessEngineOptions.SectionName));
|
||||
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
|
||||
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
|
||||
```
|
||||
|
||||
```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> 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 {
|
||||
<<interface>>
|
||||
+int Skill
|
||||
+GetBestMoveAsync(fen, ct) Task~string~
|
||||
+DisposeAsync() ValueTask
|
||||
}
|
||||
class Stockfish
|
||||
class CustomChessEngine { -P/Invoke chess_engine (.dll/.so) }
|
||||
class IChessEngineFactory { <<interface>> +Create(skill) IChessEngine }
|
||||
class ChessEngineFactory
|
||||
class ChessEngineOptions { +ChessEngineKind Engine }
|
||||
class IComputerMoveOrchestrator { <<interface>> +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 <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 <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 <stddef.h>
|
||||
|
||||
#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 <cstring>
|
||||
#include <string>
|
||||
|
||||
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<string> 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<byte> 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<string>`, 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)<br/>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<ChessHub>` as today (`Stockfish.cs:125`). **Parallel search (Lazy SMP) stays 100% native.** **Cancellation = one atomic flag:** `engine_stop()` sets `std::atomic<bool>` 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)<br/>ONE P/Invoke crossing (~1-2ns + <1us marshal)"}}
|
||||
D --> E["NATIVE: parse_fen -> bitboards (x12 ulong)"]
|
||||
E --> F["NATIVE SEARCH LOOP<br/>make/unmake on bitboards, native eval<br/>Lazy SMP threads, poll atomic g_stop<br/>ZERO managed callbacks"]
|
||||
F --> G{{"return ~5B UCI move<br/>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\<cfg>\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
|
||||
$<$<CONFIG:Release>:/O2 /GL /DNDEBUG /arch:AVX2>
|
||||
$<$<CONFIG:Debug>:/Od /Zi>) # /Zi => .pdb for mixed-mode debugging
|
||||
target_link_options(chess_engine PRIVATE $<$<CONFIG:Release>:/LTCG> $<$<CONFIG:Debug>:/DEBUG>)
|
||||
else()
|
||||
# Portable server baseline; do NOT use -march=native (build host may differ -> SIGILL).
|
||||
target_compile_options(chess_engine PRIVATE
|
||||
$<$<CONFIG:Release>:-O3 -flto -DNDEBUG -march=x86-64-v2> # ~SSE4.2; confirm server floor
|
||||
$<$<CONFIG:Debug>:-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
|
||||
<Target Name="BuildNativeWindows" BeforeTargets="BeforeBuild"
|
||||
Condition="'$(BuildNativeEngine)'=='true' AND '$(OS)'=='Windows_NT'">
|
||||
<Exec Command="cmake -S native\chess_engine -B native\chess_engine\build -A x64" />
|
||||
<Exec Command="cmake --build native\chess_engine\build --config $(Configuration)" />
|
||||
<Copy SourceFiles="native\chess_engine\build\$(Configuration)\chess_engine.dll" DestinationFolder="Resources\" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
<Target Name="BuildNativeLinux" BeforeTargets="BeforeBuild"
|
||||
Condition="'$(BuildNativeEngine)'=='true' AND '$(OS)'!='Windows_NT'">
|
||||
<Exec Command="cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release" />
|
||||
<Exec Command="cmake --build native/chess_engine/build" />
|
||||
<Copy SourceFiles="native/chess_engine/build/libchess_engine.so" DestinationFolder="Resources/" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
```
|
||||
|
||||
**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 `<Link>` 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<br/>=> chess_engine.dll (+pdb in Debug)"]
|
||||
L["Linux (ubuntu:22.04 container): cmake --build Release<br/>=> 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<br/>FEN -> UCI"]
|
||||
end
|
||||
subgraph Perf["Performance"]
|
||||
ONCE["once-per-move boundary<br/>native search"]
|
||||
end
|
||||
subgraph DevOps["DevOps"]
|
||||
PKG["commit .dll/.so in Resources<br/>DllImportResolver"]
|
||||
end
|
||||
IFACE -.->|"GetBestMoveAsync(fen) shape<br/>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 |
|
||||
@@ -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
|
||||
$<$<CONFIG:Release>:/O2 /GL /DNDEBUG /arch:AVX2>
|
||||
$<$<CONFIG:Debug>:/Od /Zi>) # /Zi => .pdb for mixed-mode debugging
|
||||
target_link_options(chess_engine PRIVATE
|
||||
$<$<CONFIG:Release>:/LTCG>
|
||||
$<$<CONFIG:Debug>:/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
|
||||
$<$<CONFIG:Release>:-O3 -flto -DNDEBUG -march=x86-64-v2>
|
||||
$<$<CONFIG:Debug>:-O0 -g>)
|
||||
endif()
|
||||
@@ -0,0 +1,179 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Debug|Win32">
|
||||
<Configuration>Debug</Configuration>
|
||||
<Platform>Win32</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|Win32">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Win32</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Debug|x64">
|
||||
<Configuration>Debug</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|x64">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>18.0</VCProjectVersion>
|
||||
<Keyword>Win32Proj</Keyword>
|
||||
<ProjectGuid>{2579bbbc-1830-4342-bc10-0a4182dc84c7}</ProjectGuid>
|
||||
<RootNamespace>chessengine</RootNamespace>
|
||||
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>true</UseDebugLibraries>
|
||||
<PlatformToolset>v145</PlatformToolset>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<PlatformToolset>v145</PlatformToolset>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>true</UseDebugLibraries>
|
||||
<PlatformToolset>v145</PlatformToolset>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<PlatformToolset>v145</PlatformToolset>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<ImportGroup Label="ExtensionSettings">
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="Shared">
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<PropertyGroup Label="UserMacros" />
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>WIN32;_DEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp20</LanguageStandard>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<AdditionalIncludeDirectories>$(ProjectDir)..\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
<EnableUAC>false</EnableUAC>
|
||||
</Link>
|
||||
<Manifest>
|
||||
<EnableSegmentHeap>true</EnableSegmentHeap>
|
||||
</Manifest>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>WIN32;NDEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp20</LanguageStandard>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<AdditionalIncludeDirectories>$(ProjectDir)..\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
<EnableUAC>false</EnableUAC>
|
||||
</Link>
|
||||
<Manifest>
|
||||
<EnableSegmentHeap>true</EnableSegmentHeap>
|
||||
</Manifest>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>_DEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp20</LanguageStandard>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<AdditionalIncludeDirectories>$(ProjectDir)..\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
<EnableUAC>false</EnableUAC>
|
||||
</Link>
|
||||
<Manifest>
|
||||
<EnableSegmentHeap>true</EnableSegmentHeap>
|
||||
</Manifest>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>NDEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp20</LanguageStandard>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<AdditionalIncludeDirectories>$(ProjectDir)..\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
<EnableUAC>false</EnableUAC>
|
||||
</Link>
|
||||
<Manifest>
|
||||
<EnableSegmentHeap>true</EnableSegmentHeap>
|
||||
</Manifest>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="..\src\chess_engine.cpp" />
|
||||
<ClCompile Include="..\src\bitboard.cpp" />
|
||||
<ClCompile Include="..\src\zobrist.cpp" />
|
||||
<ClCompile Include="..\src\position.cpp" />
|
||||
<ClCompile Include="..\src\movegen.cpp" />
|
||||
<ClCompile Include="..\src\uci.cpp" />
|
||||
<ClCompile Include="..\src\perft.cpp" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="..\include\chess_engine.h" />
|
||||
<ClInclude Include="..\src\types.h" />
|
||||
<ClInclude Include="..\src\bitboard.h" />
|
||||
<ClInclude Include="..\src\zobrist.h" />
|
||||
<ClInclude Include="..\src\position.h" />
|
||||
<ClInclude Include="..\src\movegen.h" />
|
||||
<ClInclude Include="..\src\uci.h" />
|
||||
<ClInclude Include="..\src\perft.h" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
<ImportGroup Label="ExtensionTargets">
|
||||
</ImportGroup>
|
||||
<Target Name="CopyEngineToResources" AfterTargets="Build">
|
||||
<!-- Copy the DLL plus its .pdb so native breakpoints bind when the .NET host loads the engine. -->
|
||||
<Copy SourceFiles="$(TargetPath);$(TargetDir)$(TargetName).pdb" DestinationFolder="$(ProjectDir)..\..\..\JoshHeaps.Net\Resources\" SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup>
|
||||
<Filter Include="Source Files">
|
||||
<UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
|
||||
<Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions>
|
||||
</Filter>
|
||||
<Filter Include="Header Files">
|
||||
<UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier>
|
||||
<Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions>
|
||||
</Filter>
|
||||
<Filter Include="Resource Files">
|
||||
<UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier>
|
||||
<Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions>
|
||||
</Filter>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="..\src\chess_engine.cpp">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="..\src\bitboard.cpp">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="..\src\zobrist.cpp">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="..\src\position.cpp">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="..\src\movegen.cpp">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="..\src\uci.cpp">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="..\src\perft.cpp">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="..\include\chess_engine.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\src\types.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\src\bitboard.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\src\zobrist.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\src\position.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\src\movegen.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\src\uci.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="..\src\perft.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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 <stddef.h>
|
||||
|
||||
/* ---- 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 */
|
||||
@@ -0,0 +1,146 @@
|
||||
#include "bitboard.h"
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
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
|
||||
@@ -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 <intrin.h>
|
||||
#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
|
||||
@@ -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 <cstdlib>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <new>
|
||||
#include <string>
|
||||
#include <memory>
|
||||
|
||||
|
||||
/* 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<size_t>(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>(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<int>::min();
|
||||
int bestForBlack = std::numeric_limits<int>::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" */
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,347 @@
|
||||
#include "position.h"
|
||||
#include "zobrist.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
|
||||
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>();
|
||||
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
|
||||
@@ -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 <string>
|
||||
#include <string_view>
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,50 @@
|
||||
#include "uci.h"
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
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
|
||||
@@ -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 <string>
|
||||
#include <string_view>
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user