Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be07f7504e | ||
|
|
b651683e63 | ||
|
|
d87cea572f | ||
|
|
5345427e0c | ||
|
|
76b054ee9d | ||
|
|
2db95cec34 | ||
|
|
fc2a2e122b | ||
|
|
c3280aa6d3 | ||
|
|
d2ae34f530 | ||
|
|
52d468a46e | ||
|
|
7fcfb270a1 | ||
|
|
ea60ee5a4b | ||
|
|
3dc1bc452e | ||
|
|
99a9474ef7 | ||
|
|
4884c32fc8 | ||
|
|
8043eee4cc | ||
|
|
5fd3a5d7c1 | ||
|
|
9969c6e3cf | ||
|
|
0788015cb0 | ||
|
|
34de3c1abc | ||
|
|
311d62a719 | ||
|
|
4217bf3703 | ||
|
|
fd68ada82e | ||
|
|
7c77cd0e52 | ||
|
|
93bdb3518a | ||
|
|
69e1e8450d | ||
|
|
e74de40d2e | ||
|
|
9b86bfafe0 |
@@ -3,7 +3,9 @@
|
||||
"allow": [
|
||||
"Bash(dotnet test:*)",
|
||||
"Bash(dotnet build)",
|
||||
"Bash(dir:*)"
|
||||
"Bash(dir:*)",
|
||||
"Bash(dotnet add:*)",
|
||||
"Bash(dotnet build:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -14,7 +14,9 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
# Pinned (not ubuntu-latest) to match the prod server's Ubuntu/glibc so the
|
||||
# compiled libchess_engine.so loads there. Keep this == the server's release.
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -24,9 +26,16 @@ jobs:
|
||||
with:
|
||||
dotnet-version: '8.0.x' # adjust if needed
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj
|
||||
|
||||
- name: Build native chess engine (libchess_engine.so)
|
||||
run: |
|
||||
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build native/chess_engine/build
|
||||
cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/
|
||||
|
||||
- name: Publish
|
||||
run: dotnet publish -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: |
|
||||
|
||||
@@ -361,3 +361,6 @@ MigrationBackup/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
|
||||
native/**/build/
|
||||
@@ -11,6 +11,19 @@ public class ApiTests : PageTest
|
||||
private IAPIRequestContext? _apiContext;
|
||||
private TestConfiguration Config => TestConfiguration.Instance;
|
||||
|
||||
[OneTimeSetUp]
|
||||
public async Task OneTimeSetUp()
|
||||
{
|
||||
if (!Config.Playwright.Headless)
|
||||
{
|
||||
Environment.SetEnvironmentVariable("HEADED", "1");
|
||||
}
|
||||
if (Config.Playwright.SlowMotion > 0)
|
||||
{
|
||||
Environment.SetEnvironmentVariable("PWSLOWMO", Config.Playwright.SlowMotion.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[SetUp]
|
||||
public async Task Setup()
|
||||
{
|
||||
|
||||
+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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: My First Post
|
||||
date: 2026-03-06
|
||||
summary: Welcome to my blog! Here I'll share thoughts on software development, projects I'm working on, and things I find interesting.
|
||||
tags: [dev, personal]
|
||||
---
|
||||
|
||||
# Welcome to My Blog
|
||||
|
||||
I've been meaning to start writing about the things I build and learn, and I'm finally getting around to it.
|
||||
|
||||
## What to Expect
|
||||
|
||||
I plan to write about:
|
||||
|
||||
- **Projects** I'm working on, like this website and the other things on my portfolio
|
||||
- **Software development** tips and patterns I find useful
|
||||
- **Problem solving** approaches that have helped me grow as a developer
|
||||
|
||||
## Why a Blog?
|
||||
|
||||
Building things is great, but explaining *how* and *why* you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.
|
||||
|
||||
Stay tuned for more posts!
|
||||
@@ -0,0 +1,20 @@
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JoshHeaps.Net.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class BlogController(IBlogService blogService, IConfiguration configuration) : ControllerBase
|
||||
{
|
||||
[HttpPost("invalidate")]
|
||||
public IActionResult InvalidateCache([FromHeader(Name = "X-Invalidate-Key")] string? key)
|
||||
{
|
||||
var expectedKey = configuration["BlogApi:InvalidateKey"];
|
||||
if (string.IsNullOrEmpty(expectedKey) || key != expectedKey)
|
||||
return Unauthorized();
|
||||
|
||||
blogService.ClearCache();
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using JoshHeaps.Net.Hubs;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
@@ -11,8 +12,10 @@ namespace JoshHeaps.Net.Controllers;
|
||||
[Route("api/[controller]")]
|
||||
public class ChessController(
|
||||
IChessService chessService,
|
||||
IHubContext<ChessHub> chessHub,
|
||||
IBackgroundTaskQueue queue) : ControllerBase
|
||||
IBackgroundTaskQueue queue,
|
||||
IChessEngineFactory engineFactory,
|
||||
IComputerMoveOrchestrator orchestrator,
|
||||
IHubContext<ChessHub> chessHub) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Store of ongoing games.
|
||||
@@ -22,8 +25,10 @@ public class ChessController(
|
||||
private static readonly ConcurrentDictionary<Guid, CancellationTokenSource> _gameRemovalCancellationTokens = [];
|
||||
|
||||
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
|
||||
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
|
||||
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new chess game and store it in-memory.
|
||||
@@ -42,7 +47,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 +62,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!);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,6 +76,31 @@ public class ChessController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a game the computer plays against itself and auto-play it move by move,
|
||||
/// broadcasting each move so it can be watched on the spectator page.
|
||||
/// </summary>
|
||||
[HttpGet("watch/cpu")]
|
||||
[HttpGet("watch/cpu/{difficulty}")]
|
||||
public ActionResult CreateSelfPlayGame(int difficulty = 4)
|
||||
{
|
||||
var gameState = chessService.CreateNewGame();
|
||||
_games[gameState.GameId] = gameState;
|
||||
|
||||
gameState.IsVsComputer = true;
|
||||
gameState.IsComputerVsComputer = true;
|
||||
gameState.WhiteJoined = true;
|
||||
gameState.BlackJoined = true;
|
||||
gameState.WhitePlayerId = Guid.NewGuid();
|
||||
gameState.BlackPlayerId = Guid.NewGuid();
|
||||
gameState.Computer = engineFactory.Create(difficulty);
|
||||
|
||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||
StartSelfPlay(gameState);
|
||||
|
||||
return Ok(new { gameState.GameId });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Joins the "pool" of chess players.
|
||||
/// Test code expects to receive a GUID for the player
|
||||
@@ -113,6 +143,30 @@ public class ChessController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List in-progress games for spectators: both sides present and the game not yet decided.
|
||||
/// </summary>
|
||||
[HttpGet("active")]
|
||||
public ActionResult GetActiveGames()
|
||||
{
|
||||
var activeGames = _games.Values
|
||||
// In-progress games, plus finished computer-vs-computer games still in their result window.
|
||||
.Where(g => g.WhiteJoined && g.BlackJoined
|
||||
&& ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer))
|
||||
.Select(g => new
|
||||
{
|
||||
g.GameId,
|
||||
g.IsVsComputer,
|
||||
g.IsComputerVsComputer,
|
||||
CurrentPlayer = g.CurrentPlayer.ToString(),
|
||||
MoveCount = g.MoveHistory.Count,
|
||||
g.IsCheck
|
||||
})
|
||||
.OrderByDescending(g => g.MoveCount);
|
||||
|
||||
return Ok(activeGames);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the state of an existing game by ID.
|
||||
/// </summary>
|
||||
@@ -129,6 +183,7 @@ public class ChessController(
|
||||
gameState.IsCheck,
|
||||
gameState.IsCheckmate,
|
||||
gameState.IsStalemate,
|
||||
gameState.IsThreefoldRepetition,
|
||||
EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null,
|
||||
gameState.WhiteCanCastleKingside,
|
||||
gameState.WhiteCanCastleQueenside,
|
||||
@@ -165,7 +220,7 @@ public class ChessController(
|
||||
var expectedPlayerId = isWhiteMove ? gameState.WhitePlayerId : gameState.BlackPlayerId;
|
||||
|
||||
if (moveDto.PlayerId != expectedPlayerId)
|
||||
return Forbid("You are not the current player.");
|
||||
return StatusCode(403, "You are not the current player.");
|
||||
|
||||
// Make sure player owns the piece
|
||||
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == moveDto.PieceId);
|
||||
@@ -174,26 +229,70 @@ public class ChessController(
|
||||
return NotFound("Chess piece Id does not exist");
|
||||
|
||||
if ((isWhiteMove && piece.Color != PieceColor.White) || (!isWhiteMove && piece?.Color != PieceColor.Black))
|
||||
return Forbid("You cannot move this piece.");
|
||||
return StatusCode(403, "You cannot move this piece.");
|
||||
|
||||
var result = chessService.MakeMove(gameState, moveDto);
|
||||
|
||||
if (!result.Success)
|
||||
return BadRequest(result);
|
||||
|
||||
if (result.IsCheckmate || result.IsStalemate)
|
||||
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
|
||||
|
||||
if (isGameOver)
|
||||
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
||||
else if (gameState.IsVsComputer)
|
||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||
else
|
||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
||||
|
||||
if (gameState.IsVsComputer && gameState.Computer is not null)
|
||||
queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService));
|
||||
if (!isGameOver && gameState.IsVsComputer && gameState.Computer is not null)
|
||||
queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!));
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forfeit a game on behalf of the calling player, handing the win to the opponent.
|
||||
/// Used when a player abandons a game (e.g. starts a new one mid-game).
|
||||
/// </summary>
|
||||
[HttpPost("forfeit")]
|
||||
public async Task<ActionResult> Forfeit([FromBody] ForfeitDto forfeit)
|
||||
{
|
||||
if (!_games.TryGetValue(forfeit.GameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited)
|
||||
return Ok();
|
||||
|
||||
var isWhitePlayer = gameState.WhitePlayerId == forfeit.PlayerId;
|
||||
var isBlackPlayer = gameState.BlackPlayerId == forfeit.PlayerId;
|
||||
|
||||
if (!isWhitePlayer && !isBlackPlayer)
|
||||
return StatusCode(403, "You are not a player in this game.");
|
||||
|
||||
gameState.IsForfeited = true;
|
||||
gameState.Winner = isWhitePlayer ? PieceColor.Black : PieceColor.White;
|
||||
|
||||
await chessHub.Clients.Group(gameState.GameId.ToString())
|
||||
.SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit");
|
||||
|
||||
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export a game as PGN (works while the game is still in memory after it ends).
|
||||
/// </summary>
|
||||
[HttpGet("{gameId}/pgn")]
|
||||
public ActionResult GetPgn(Guid gameId)
|
||||
{
|
||||
if (!_games.TryGetValue(gameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
return Content(gameState.ToPgn(), "application/x-chess-pgn");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the legal moves for a specific piece in a specific game.
|
||||
/// </summary>
|
||||
@@ -227,6 +326,42 @@ public class ChessController(
|
||||
return Ok(allMoves);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives a computer-vs-computer game: keeps asking the engine for the side-to-move's
|
||||
/// move (which applies and broadcasts it) until the game ends or is removed.
|
||||
/// </summary>
|
||||
private void StartSelfPlay(GameState gameState)
|
||||
{
|
||||
queue.Queue(async () =>
|
||||
{
|
||||
// Give spectators a moment to join the SignalR group before the first move.
|
||||
await Task.Delay(TimeSpan.FromSeconds(1));
|
||||
|
||||
while (_games.ContainsKey(gameState.GameId)
|
||||
&& !gameState.IsCheckmate
|
||||
&& !gameState.IsStalemate
|
||||
&& !gameState.IsThreefoldRepetition
|
||||
&& !gameState.IsForfeited)
|
||||
{
|
||||
try
|
||||
{
|
||||
await orchestrator.PlayAsync(gameState, gameState.Computer!);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(_selfPlayMoveDelay);
|
||||
}
|
||||
|
||||
// Leave the finished game in place briefly so spectators can see the result.
|
||||
if (_games.ContainsKey(gameState.GameId))
|
||||
ScheduleRemoveGame(gameState.GameId, _selfPlayResultTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
|
||||
{
|
||||
if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts))
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>53ed685c-bdff-4306-8cc2-9fbe55c85713</UserSecretsId>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -27,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>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
public class BlogPost
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public DateTime PublishedDate { get; set; }
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public string HtmlContent { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
public class ForfeitDto
|
||||
{
|
||||
public Guid GameId { get; set; }
|
||||
public Guid PlayerId { get; set; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using JoshHeaps.Net.Services.Implementations;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
@@ -28,10 +28,25 @@ public class GameState
|
||||
public bool IsCheck { get; set; }
|
||||
public bool IsCheckmate { get; set; }
|
||||
public bool IsStalemate { get; set; }
|
||||
public bool IsThreefoldRepetition { get; set; }
|
||||
public bool IsForfeited { get; set; } = false;
|
||||
|
||||
// The color that won, when the game ended by forfeit (null while the game is live).
|
||||
public PieceColor? Winner { get; set; }
|
||||
|
||||
// Keep a history of moves if desired
|
||||
public List<string> MoveHistory { get; set; }
|
||||
|
||||
// Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection.
|
||||
public List<string> PositionHistory { get; set; }
|
||||
|
||||
// Moves in standard algebraic notation (SAN), in order, for PGN export.
|
||||
public List<string> SanHistory { get; set; }
|
||||
|
||||
// Half-moves since the last capture or pawn move (the FEN 50-move clock). Also tells
|
||||
// us how many trailing PositionHistory entries belong to the current repetition window.
|
||||
public int HalfmoveClock { get; set; }
|
||||
|
||||
// A list of all pieces to quickly reference them (optional but convenient).
|
||||
// Alternatively, you can iterate the Board array.
|
||||
public List<ChessPiece> Pieces { get; set; }
|
||||
@@ -42,8 +57,9 @@ public class GameState
|
||||
public Guid BlackPlayerId { get; set; }
|
||||
|
||||
public bool IsVsComputer { get; set; } = false;
|
||||
public bool IsComputerVsComputer { get; set; } = false;
|
||||
|
||||
public Stockfish? Computer { get; set; }
|
||||
public IChessEngine? Computer { get; set; }
|
||||
|
||||
// optional: convenience
|
||||
public bool IsOpen => !WhiteJoined || !BlackJoined;
|
||||
@@ -54,5 +70,7 @@ public class GameState
|
||||
Board = new ChessPiece[8, 8];
|
||||
Pieces = [];
|
||||
MoveHistory = [];
|
||||
PositionHistory = [];
|
||||
SanHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ public class MoveResultDto
|
||||
public bool IsCheck { get; set; }
|
||||
public bool IsCheckmate { get; set; }
|
||||
public bool IsStalemate { get; set; }
|
||||
public bool IsThreefoldRepetition { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.BlogModel
|
||||
@{
|
||||
ViewData["Title"] = "Blog - JoshHeaps.Net";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div class="blog-index">
|
||||
<h1 class="blog-title">Blog</h1>
|
||||
|
||||
@if (Model.ActiveTag is not null)
|
||||
{
|
||||
<div class="blog-filter">
|
||||
<span>Filtered by: <strong>@Model.ActiveTag</strong></span>
|
||||
<a href="/blog">Clear filter</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Posts.Count == 0)
|
||||
{
|
||||
<p class="blog-empty">No posts yet. Check back soon!</p>
|
||||
}
|
||||
|
||||
<div class="blog-list">
|
||||
@foreach (var post in Model.Posts)
|
||||
{
|
||||
<article class="blog-card">
|
||||
<a href="/blog/@post.Slug" class="blog-card-link">
|
||||
<h2 class="blog-card-title">@post.Title</h2>
|
||||
</a>
|
||||
<time class="blog-card-date" datetime="@post.PublishedDate.ToString("yyyy-MM-dd")">
|
||||
@post.PublishedDate.ToString("MMMM d, yyyy")
|
||||
</time>
|
||||
<p class="blog-card-summary">@post.Summary</p>
|
||||
<div class="blog-card-tags">
|
||||
@foreach (var tag in post.Tags)
|
||||
{
|
||||
<a href="/blog?tag=@tag" class="blog-tag">@tag</a>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/blog/index.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages;
|
||||
|
||||
public class BlogModel : PageModel
|
||||
{
|
||||
private readonly IBlogService _blogService;
|
||||
|
||||
public List<BlogPost> Posts { get; set; } = [];
|
||||
public string? ActiveTag { get; set; }
|
||||
|
||||
public BlogModel(IBlogService blogService)
|
||||
{
|
||||
_blogService = blogService;
|
||||
}
|
||||
|
||||
public async Task OnGetAsync(string? tag)
|
||||
{
|
||||
ActiveTag = tag;
|
||||
Posts = tag is not null
|
||||
? await _blogService.GetPostsByTagAsync(tag)
|
||||
: await _blogService.GetAllPostsAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
@page "/blog/{slug}"
|
||||
@model JoshHeaps.Net.Pages.Blog.PostModel
|
||||
@{
|
||||
ViewData["Title"] = Model.Post!.Title + " - JoshHeaps.Net";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<article class="blog-post">
|
||||
<a href="/blog" class="blog-back">← Back to blog</a>
|
||||
|
||||
<header class="blog-post-header">
|
||||
<h1 class="blog-post-title">@Model.Post.Title</h1>
|
||||
<time class="blog-post-date" datetime="@Model.Post.PublishedDate.ToString("yyyy-MM-dd")">
|
||||
@Model.Post.PublishedDate.ToString("MMMM d, yyyy")
|
||||
</time>
|
||||
<div class="blog-post-tags">
|
||||
@foreach (var tag in Model.Post.Tags)
|
||||
{
|
||||
<a href="/blog?tag=@tag" class="blog-tag">@tag</a>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="post-content">
|
||||
@Html.Raw(Model.Post.HtmlContent)
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/blog/post.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/blog/prism-tomorrow.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js"></script>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages.Blog;
|
||||
|
||||
public class PostModel : PageModel
|
||||
{
|
||||
private readonly IBlogService _blogService;
|
||||
|
||||
public BlogPost? Post { get; set; }
|
||||
|
||||
public PostModel(IBlogService blogService)
|
||||
{
|
||||
_blogService = blogService;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(string slug)
|
||||
{
|
||||
Post = await _blogService.GetPostBySlugAsync(slug);
|
||||
|
||||
if (Post is null)
|
||||
return NotFound();
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@
|
||||
<div id="buttonContainer" class="sideContent">
|
||||
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
|
||||
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button>
|
||||
<button id="copyPgnBtn" onclick="copyPgn()" style="display: none">Copy PGN</button>
|
||||
<a id="watchLink" href="/watch">Watch other games →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,101 +1,116 @@
|
||||
@page
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "JoshHeaps.Net";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<header id="Header">
|
||||
<a class="headerOption" onclick="showClickedContents('projects')">./ Projects</a>
|
||||
<a class="headerOption" onclick="showClickedContents('demos')">./ Demos</a>
|
||||
<a class="headerOption" onclick="showClickedContents('contact')">./ Contact me</a>
|
||||
</header>
|
||||
|
||||
<div id="WelcomeMessage"></div>
|
||||
|
||||
<div id="ProjectsBox">
|
||||
<div id="ChessProject" class="projectContainer">
|
||||
<div class="displayBox diagonal-section-left">
|
||||
<a target="_blank" href="https://github.com/JoshHeaps/OnlineChess">
|
||||
<img id="ChessImage" class="projectImage projectImageLeft" src="/images/Chess.jpg" title="Chessboard"/>
|
||||
</a>
|
||||
</div>
|
||||
<div id="ChessText" class="projectTextRight">
|
||||
<h2 id="ChessTitle" class="projectHeaderRight projectHeader">Chess</h2>
|
||||
<p id="ChessDescription" class="projectDescriptionRight projectDescription">
|
||||
As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. This project is a work in progress, as currently it can only be played locally. It was built in C# on .NET 8.0, using winforms, because I like a challenge. I plan to add online multiplayer functionality in the future.
|
||||
</p>
|
||||
</div>
|
||||
<nav id="Header">
|
||||
<span class="site-name">josh heaps</span>
|
||||
<div class="nav-links">
|
||||
<a class="nav-link" onclick="showClickedContents('projects')">Projects</a>
|
||||
<a class="nav-link" onclick="showClickedContents('demos')">Demos</a>
|
||||
<a class="nav-link" onclick="showClickedContents('blog')">Blog</a>
|
||||
<a class="nav-link" onclick="showClickedContents('contact')">Contact</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div id="CompilerProject" class="projectContainer">
|
||||
<div id="CompilerText" class="projectTextLeft">
|
||||
<h2 id="CompilerTitle" class="projectHeaderLeft projectHeader">Compiler</h2>
|
||||
<p id="CompilerDescription" class="projectDescriptionLeft projectDescription">
|
||||
I love solving problems, and figuring out how things work. What better way to do both than writing a compiler myself? This project is a compiler that I wrote in C# on .NET 8.0. It takes in a simple language that I created, outputs the corresponding common intermediate language (CIL), and executes it. I plan to add more features in the future.
|
||||
</p>
|
||||
</div>
|
||||
<div class="displayBox diagonal-section-right">
|
||||
<a target="_blank" href="https://github.com/JoshHeaps/CILCompiler">
|
||||
<img id="CompilerImage" class="projectImage projectImageRight" src="/images/CompilerDemo.png" title="Compiler Input and Output"/>
|
||||
<section id="Hero">
|
||||
<div id="WelcomeMessage"></div>
|
||||
</section>
|
||||
|
||||
<section id="ProjectsBox">
|
||||
<h2 class="section-title">Projects</h2>
|
||||
<div class="projects-grid">
|
||||
<article class="project-card">
|
||||
<a href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank" class="project-image-link">
|
||||
<img class="projectImage" src="/images/Chess.jpg" alt="Chessboard" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-info">
|
||||
<h3 class="project-title">Chess</h3>
|
||||
<p class="project-description">As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. It was made using .NET razor pages, a .NET web api backend, and a good amount of javascript on the frontend. Feel free to check it out in the demos below :)</p>
|
||||
<a class="project-link" href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank">View on GitHub →</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div id="DemosBox" class="displayBox">
|
||||
<h2 id="DemoHeader">Demos</h2>
|
||||
<article class="project-card">
|
||||
<a href="https://github.com/JoshHeaps/CILCompiler" target="_blank" class="project-image-link">
|
||||
<img class="projectImage" src="/images/CompilerDemo.png" alt="Compiler Input and Output" />
|
||||
</a>
|
||||
<div class="project-info">
|
||||
<h3 class="project-title">Compiler</h3>
|
||||
<p class="project-description">I love solving problems, and figuring out how things work. What better way to do both than writing a compiler myself? This project is a compiler that I wrote in C# on .NET 8.0. It takes in a simple language that I created, outputs the corresponding common intermediate language (CIL), and executes it. I plan to add more features in the future.</p>
|
||||
<a class="project-link" href="https://github.com/JoshHeaps/CILCompiler" target="_blank">View on GitHub →</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="project-card">
|
||||
<a href="https://media.joshheaps.net" target="_blank" class="project-image-link">
|
||||
<img class="projectImage" src="/images/CloudStorageDemo.png" alt="Cloud Storage Screenshot" />
|
||||
</a>
|
||||
<div class="project-info">
|
||||
<h3 class="project-title">Image Cloud Storage</h3>
|
||||
<p class="project-description">My wife and I LOVE to take pictures and videos of our kids, but we do not like paying for extra cloud storage. I made this for my family so we can upload pictures to my server, share them with each other, and now we don't need to worry about how much space we're using.</p>
|
||||
<a class="project-link" href="https://media.joshheaps.net" target="_blank">View Live Demo →</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="DemosBox">
|
||||
<h2 class="section-title">Demos</h2>
|
||||
<div id="buttonWrapper">
|
||||
<button id="ChessDemo" class="demoButton" onclick="window.location.href='/chess'">
|
||||
Play Chess
|
||||
</button>
|
||||
<button id="ParticleDemo" class="demoButton" onclick="window.location.href='/particles'">
|
||||
Particle Simulator
|
||||
</button>
|
||||
<button id="MemoryLane" class="demoButton" onclick="window.location.href='/memorylane'">
|
||||
Memory Lane
|
||||
</button>
|
||||
<button id="CloadStorageDemo" class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">
|
||||
Cloud Image Storage
|
||||
</button>
|
||||
<button id="MoreFiller" class="demoButton">
|
||||
More coming soon...
|
||||
</button>
|
||||
<button class="demoButton" onclick="window.location.href='/chess'">Play Chess</button>
|
||||
<button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button>
|
||||
<button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
|
||||
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
|
||||
<button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
|
||||
<button id="MoreFiller" class="demoButton">More coming soon...</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="BlogBox">
|
||||
<h2 class="section-title">Latest Posts</h2>
|
||||
<div id="blogPostsList">
|
||||
@foreach (var post in Model.LatestPosts)
|
||||
{
|
||||
<div class="blog-teaser-item">
|
||||
<a class="blog-teaser-title" href="/blog/@post.Slug">@post.Title</a>
|
||||
<span class="blog-teaser-date">@post.PublishedDate.ToString("MMMM d, yyyy")</span>
|
||||
<p class="blog-teaser-summary">@post.Summary</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<a class="blog-view-all" href="/blog">View all posts →</a>
|
||||
</section>
|
||||
|
||||
<footer id="Contacts">
|
||||
<svg viewBox="0 0 16 16" class="social-icon">
|
||||
<a id="GithubLink" class="social-link" href="https://github.com/JoshHeaps" target="_blank">
|
||||
<a class="social-link" href="https://github.com/JoshHeaps" target="_blank">
|
||||
<svg viewBox="0 0 16 16" class="social-icon">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
|
||||
<rect width="100%" height="100%" fill="transparent" />
|
||||
</a>
|
||||
</svg>
|
||||
|
||||
<svg viewBox="0 0 24 24" class="social-icon">
|
||||
<a id="LinkedinLink" class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
|
||||
</svg>
|
||||
</a>
|
||||
<a class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
|
||||
<svg viewBox="0 0 24 24" class="social-icon">
|
||||
<path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19a.66.66 0 000 .14V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z"></path>
|
||||
<rect width="100%" height="100%" fill="transparent" />
|
||||
</a>
|
||||
</svg>
|
||||
|
||||
<svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none">
|
||||
<a id="GmailLink" class="social-link" href="mailto:[email protected]">
|
||||
</svg>
|
||||
</a>
|
||||
<a class="social-link" href="mailto:[email protected]">
|
||||
<svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none">
|
||||
<path d="m0 3.23863636v2.72727273l3.12784091 3.02727273 3.69034091 2.08636368.68181818-4.59034095-.68181818-4.27329546-1.90909091-1.43181818c-2.02329546-1.51704546-4.90909091-.07329545-4.90909091 2.45454545"/>
|
||||
<path d="m23.1818182 2.21590909-.6818182 4.32954546.6818182 4.53409095 3.3494318-1.65852277 3.46875-3.45511364v-2.72727273c0-2.5278409-2.8857955-3.97159091-4.9090909-2.45454545z"/>
|
||||
<path d="m2.04545455 22.6704545h4.77272727v-11.590909l-6.81818182-5.11363641v14.65909091c0 1.1301136.91534091 2.0454545 2.04545455 2.0454545"/>
|
||||
<path d="m23.1818182 22.6704545h4.7727273c1.1301136 0 2.0454545-.9153409 2.0454545-2.0454545v-14.65909091l-6.8181818 5.11363641z"/>
|
||||
<path d="m15 8.35227273-8.18181818-6.13636364v8.86363641l8.18181818 6.1363636 8.1818182-6.1363636v-8.86363641z"/>
|
||||
<rect width="100%" height="100%" fill="transparent" />
|
||||
</a>
|
||||
</svg>
|
||||
</svg>
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
@section Styles {
|
||||
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/site.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/project.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/imageLeftProject.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/imageRightProject.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/blog.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
<link href="/css/home/footer.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages
|
||||
{
|
||||
public class IndexModel() : PageModel
|
||||
public class IndexModel(IBlogService blogService) : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
public List<BlogPost> LatestPosts { get; set; } = [];
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
var allPosts = await blogService.GetAllPostsAsync();
|
||||
LatestPosts = allPosts.Take(3).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.WatchModel
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
ViewData["Title"] = "Watch Chess";
|
||||
}
|
||||
|
||||
<div id="watchHeader">
|
||||
<h1>Live Chess</h1>
|
||||
<p id="watchStatus">Loading games…</p>
|
||||
<div id="watchControls">
|
||||
<label for="cpuDifficulty">Difficulty</label>
|
||||
<select id="cpuDifficulty">
|
||||
@for (int i = 1; i <= 20; i++)
|
||||
{
|
||||
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
|
||||
}
|
||||
</select>
|
||||
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
|
||||
</div>
|
||||
<a id="backToPlay" href="/chess">← Play a game</a>
|
||||
</div>
|
||||
|
||||
<div id="gamesFeed"></div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/signalr/signalr.min.js"></script>
|
||||
<script src="~/js/ChessScripts/Spectate.js"></script>
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
|
||||
<link rel="stylesheet" href="~/css/chess/spectate.css?v=@ViewData["cssVersion"]" />
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages
|
||||
{
|
||||
public class WatchModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@{
|
||||
Layout = null;
|
||||
ViewData["cssVersion"] = "1.0.2"; // <--- change this once to bust cache
|
||||
ViewData["cssVersion"] = "1.0.5"; // <--- change this once to bust cache
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
|
||||
@@ -12,9 +12,20 @@ builder.Services.AddControllers();
|
||||
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
builder.Services.AddHttpClient("BlogApi", client =>
|
||||
{
|
||||
var baseUrl = configuration["BlogApi:BaseUrl"] ?? "https://media.joshheaps.net";
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(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.
@@ -8,31 +8,23 @@ public class AutoIpUpdateService(
|
||||
ILogger<AutoIpUpdateService> log)
|
||||
: BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(5);
|
||||
private static readonly HttpClient httpClient = new();
|
||||
public static bool IsEnabled { get; private set; } = false;
|
||||
|
||||
private static readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1);
|
||||
private static readonly HttpClient _httpClient = new();
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stop)
|
||||
{
|
||||
IsEnabled = true;
|
||||
var timer = new PeriodicTimer(CheckInterval);
|
||||
AAAARecord dnsRecord = await GetDnsRecordAsync();
|
||||
string lastKnownIp = dnsRecord.Content;
|
||||
var timer = new PeriodicTimer(_checkInterval);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(stop))
|
||||
{
|
||||
try
|
||||
{
|
||||
string currentIp = await GetPublicIpAsync() ?? "";
|
||||
|
||||
if (lastKnownIp != currentIp)
|
||||
{
|
||||
await UpdateDnsIpAsync(config, dnsRecord, currentIp);
|
||||
|
||||
lastKnownIp = currentIp;
|
||||
}
|
||||
await UpdateIpAddressIfChanged();
|
||||
}
|
||||
catch (OperationCanceledException) { /* shutting down */ }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.LogError(ex, "Error while attempting ip update");
|
||||
@@ -40,11 +32,33 @@ public class AutoIpUpdateService(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateIpAddressIfChanged()
|
||||
{
|
||||
var dnsRecords = await GetDnsRecordAsync();
|
||||
string dnsRecordIp = dnsRecords[0].Content;
|
||||
|
||||
if (!dnsRecords.Records.All(x => x.Content == dnsRecords[0].Content))
|
||||
dnsRecordIp = string.Empty;
|
||||
|
||||
string publicIp = await GetPublicIpAsync() ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(publicIp) || dnsRecordIp == publicIp)
|
||||
return;
|
||||
|
||||
foreach (var dnsRecord in dnsRecords.Records)
|
||||
{
|
||||
if (dnsRecord.Content == publicIp)
|
||||
continue;
|
||||
|
||||
await UpdateDnsIpAsync(config, dnsRecord, publicIp);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> GetPublicIpAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await httpClient.GetStringAsync(@"https://api.ipify.org/");
|
||||
return await _httpClient.GetStringAsync(@"https://api.ipify.org/");
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -55,7 +69,7 @@ public class AutoIpUpdateService(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AAAARecord> GetDnsRecordAsync()
|
||||
private async Task<RecordList> GetDnsRecordAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -64,8 +78,8 @@ public class AutoIpUpdateService(
|
||||
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
|
||||
var result = await cfClient.GetAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records");
|
||||
Console.WriteLine(await result.Content.ReadAsStringAsync());
|
||||
var records = System.Text.Json.JsonSerializer.Deserialize<RecordList>(await result.Content.ReadAsStringAsync());
|
||||
return records!.Result[0];
|
||||
var records = JsonSerializer.Deserialize<RecordList>(await result.Content.ReadAsStringAsync());
|
||||
return records!;
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -76,22 +90,23 @@ public class AutoIpUpdateService(
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task UpdateDnsIpAsync(IConfiguration config, AAAARecord record, string ip)
|
||||
private static async Task UpdateDnsIpAsync(IConfiguration config, DnsRecord record, string ip)
|
||||
{
|
||||
HttpClient cfClient = new();
|
||||
cfClient.DefaultRequestHeaders.Add("X-Auth-Email", config["cfEmail"]);
|
||||
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
|
||||
object content = new
|
||||
|
||||
object updateRequestBody = new
|
||||
{
|
||||
comment = "Update as needed",
|
||||
name = record.Name,
|
||||
ttl = record.Ttl,
|
||||
type = record.Type,
|
||||
comment = record.Comment,
|
||||
content = ip,
|
||||
name = "@",
|
||||
proxied = true,
|
||||
ttl = 3600,
|
||||
type = "AAAA"
|
||||
proxied = record.Proxied,
|
||||
};
|
||||
|
||||
var result = await cfClient.PutAsJsonAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records{record.Id}", content);
|
||||
var result = await cfClient.PatchAsJsonAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records/{record.Id}", updateRequestBody);
|
||||
|
||||
if (!result.IsSuccessStatusCode)
|
||||
{
|
||||
@@ -102,11 +117,23 @@ public class AutoIpUpdateService(
|
||||
}
|
||||
}
|
||||
|
||||
record AAAARecord(
|
||||
record DnsRecord(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("ttl")] int Ttl,
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("comment")] string Comment,
|
||||
[property: JsonPropertyName("content")] string Content,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("proxied")] bool Proxied,
|
||||
[property: JsonPropertyName("id")] string Id);
|
||||
|
||||
record RecordList([property: JsonPropertyName("result")] List<AAAARecord> Result);
|
||||
record RecordList([property: JsonPropertyName("result")] List<DnsRecord> Records)
|
||||
{
|
||||
public DnsRecord this[int index]
|
||||
{
|
||||
get
|
||||
{
|
||||
return Records[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using JoshHeaps.Net.Models;
|
||||
using JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public class BlogService : IBlogService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<BlogService> _logger;
|
||||
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public BlogService(IHttpClientFactory httpClientFactory, ILogger<BlogService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("BlogApi");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<BlogPost>> GetAllPostsAsync()
|
||||
{
|
||||
return await GetCachedAsync("all_posts",
|
||||
() => FetchAsync<List<BlogPost>>("api/blog/posts")) ?? [];
|
||||
}
|
||||
|
||||
public async Task<BlogPost?> GetPostBySlugAsync(string slug)
|
||||
{
|
||||
return await GetCachedAsync($"post_{slug}",
|
||||
() => FetchAsync<BlogPost>($"api/blog/posts/{Uri.EscapeDataString(slug)}"));
|
||||
}
|
||||
|
||||
public async Task<List<BlogPost>> GetPostsByTagAsync(string tag)
|
||||
{
|
||||
return await GetCachedAsync($"tag_{tag}",
|
||||
() => FetchAsync<List<BlogPost>>($"api/blog/posts/tags/{Uri.EscapeDataString(tag)}")) ?? [];
|
||||
}
|
||||
|
||||
private async Task<T?> FetchAsync<T>(string path) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(path);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Blog API returned {StatusCode} for {Path}", response.StatusCode, path);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<T>(json, JsonOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch from blog API: {Path}", path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
|
||||
{
|
||||
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > DateTime.UtcNow)
|
||||
return (T?)entry.Value;
|
||||
|
||||
var result = await factory();
|
||||
|
||||
if (result is not null)
|
||||
{
|
||||
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
|
||||
}
|
||||
else if (entry is not null)
|
||||
{
|
||||
// API unreachable — serve stale cache
|
||||
_logger.LogWarning("Serving stale cache for key {Key}", key);
|
||||
return (T?)entry.Value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void ClearCache()
|
||||
{
|
||||
_cache.Clear();
|
||||
_logger.LogInformation("Blog cache cleared");
|
||||
}
|
||||
|
||||
private record CacheEntry(object Value, DateTime ExpiresAt);
|
||||
}
|
||||
@@ -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,153 @@
|
||||
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(' ').Append(gs.HalfmoveClock).Append(' ').Append(fullMoves);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The prior positions since the last irreversible move (capture/pawn move), as
|
||||
/// completed FEN strings, oldest first and excluding the current position. This is
|
||||
/// the repetition window the engine needs to detect threefold/50-move draws that a
|
||||
/// single FEN can't express.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> RepetitionHistory(this GameState gs)
|
||||
{
|
||||
int clock = gs.HalfmoveClock;
|
||||
int count = gs.PositionHistory.Count;
|
||||
int start = count - 1 - clock; // PositionHistory ends with the current position
|
||||
|
||||
if (clock <= 0 || start < 0)
|
||||
return Array.Empty<string>();
|
||||
|
||||
var fens = new List<string>(clock);
|
||||
|
||||
for (int i = start; i < count - 1; i++)
|
||||
fens.Add(gs.PositionHistory[i] + " 0 1"); // complete the 4-field key into a parseable FEN
|
||||
|
||||
return fens;
|
||||
}
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
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))
|
||||
};
|
||||
}
|
||||
@@ -22,12 +22,14 @@ public class ChessService : IChessService
|
||||
|
||||
gameState.Pieces.Clear();
|
||||
gameState.MoveHistory.Clear();
|
||||
gameState.SanHistory.Clear();
|
||||
|
||||
gameState.WhiteCanCastleKingside = true;
|
||||
gameState.WhiteCanCastleQueenside = true;
|
||||
gameState.BlackCanCastleKingside = true;
|
||||
gameState.BlackCanCastleQueenside = true;
|
||||
gameState.EnPassantTarget = null;
|
||||
gameState.HalfmoveClock = 0;
|
||||
|
||||
SetupBlackPieces(gameState);
|
||||
SetupWhitePieces(gameState);
|
||||
@@ -35,6 +37,9 @@ public class ChessService : IChessService
|
||||
gameState.CurrentPlayer = PieceColor.White;
|
||||
|
||||
UpdateCheckStatus(gameState);
|
||||
|
||||
gameState.PositionHistory.Clear();
|
||||
gameState.PositionHistory.Add(PositionKey(gameState));
|
||||
}
|
||||
|
||||
private static void SetupBlackPieces(GameState gs)
|
||||
@@ -110,7 +115,6 @@ public class ChessService : IChessService
|
||||
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == pieceId);
|
||||
|
||||
if (piece == null) return [];
|
||||
if (piece.Color != gameState.CurrentPlayer) return [];
|
||||
|
||||
var candidateMoves = GenerateCandidateMoves(gameState, piece);
|
||||
var legalMoves = new List<Position>();
|
||||
@@ -138,12 +142,23 @@ public class ChessService : IChessService
|
||||
if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol))
|
||||
return new MoveResultDto { Success = false, Message = "Illegal move." };
|
||||
|
||||
// SAN is built before the move (needs the pre-move board for captures/disambiguation);
|
||||
// the check/mate suffix is appended after UpdateCheckStatus.
|
||||
var sanBase = BuildSan(gameState, piece, targetPos, moveDto);
|
||||
|
||||
PerformMove(gameState, piece, targetPos, moveDto);
|
||||
|
||||
UpdateCheckStatus(gameState);
|
||||
|
||||
var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
|
||||
gameState.MoveHistory.Add(notation);
|
||||
gameState.SanHistory.Add(sanBase + (gameState.IsCheckmate ? "#" : gameState.IsCheck ? "+" : ""));
|
||||
|
||||
var positionKey = PositionKey(gameState);
|
||||
gameState.PositionHistory.Add(positionKey);
|
||||
|
||||
if (gameState.PositionHistory.Count(k => k == positionKey) >= 3)
|
||||
gameState.IsThreefoldRepetition = true;
|
||||
|
||||
return new MoveResultDto
|
||||
{
|
||||
@@ -151,14 +166,95 @@ public class ChessService : IChessService
|
||||
Message = "Move successful.",
|
||||
IsCheck = gameState.IsCheck,
|
||||
IsCheckmate = gameState.IsCheckmate,
|
||||
IsStalemate = gameState.IsStalemate
|
||||
IsStalemate = gameState.IsStalemate,
|
||||
IsThreefoldRepetition = gameState.IsThreefoldRepetition
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the standard-algebraic notation for a move from the position BEFORE it is
|
||||
/// applied (the check/mate suffix is added by the caller afterward).
|
||||
/// </summary>
|
||||
private string BuildSan(GameState gs, ChessPiece piece, Position target, MoveDto moveDto)
|
||||
{
|
||||
var from = piece.Position;
|
||||
|
||||
if (piece.Type == PieceType.King && Math.Abs(target.Col - from.Col) == 2)
|
||||
return target.Col > from.Col ? "O-O" : "O-O-O";
|
||||
|
||||
bool targetOccupied = gs.Board[target.Row, target.Col] != null;
|
||||
bool isEnPassant = piece.Type == PieceType.Pawn && from.Col != target.Col && !targetOccupied;
|
||||
bool isCapture = targetOccupied || isEnPassant;
|
||||
string dest = SquareName(target);
|
||||
|
||||
if (piece.Type == PieceType.Pawn)
|
||||
{
|
||||
var san = isCapture ? $"{FileChar(from.Col)}x{dest}" : dest;
|
||||
|
||||
bool promotes = (piece.Color == PieceColor.White && target.Row == 0)
|
||||
|| (piece.Color == PieceColor.Black && target.Row == 7);
|
||||
|
||||
if (promotes)
|
||||
san += "=" + PieceLetter(moveDto.PromotionChoice ?? PieceType.Queen);
|
||||
|
||||
return san;
|
||||
}
|
||||
|
||||
return $"{PieceLetter(piece.Type)}{Disambiguation(gs, piece, target)}{(isCapture ? "x" : "")}{dest}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SAN disambiguation: when another piece of the same type and color can also reach the
|
||||
/// target, qualify the origin by file, else rank, else both.
|
||||
/// </summary>
|
||||
private string Disambiguation(GameState gs, ChessPiece piece, Position target)
|
||||
{
|
||||
var rivals = gs.Pieces
|
||||
.Where(p => p.Id != piece.Id && p.Type == piece.Type && p.Color == piece.Color && p.Position.Row >= 0)
|
||||
.Where(p => GetLegalMovesForPiece(gs, p.Id).Any(m => m.Row == target.Row && m.Col == target.Col))
|
||||
.ToList();
|
||||
|
||||
if (rivals.Count == 0)
|
||||
return "";
|
||||
|
||||
if (rivals.All(p => p.Position.Col != piece.Position.Col))
|
||||
return FileChar(piece.Position.Col).ToString();
|
||||
|
||||
if (rivals.All(p => p.Position.Row != piece.Position.Row))
|
||||
return (8 - piece.Position.Row).ToString();
|
||||
|
||||
return $"{FileChar(piece.Position.Col)}{8 - piece.Position.Row}";
|
||||
}
|
||||
|
||||
private static char FileChar(int col) => (char)('a' + col);
|
||||
|
||||
private static string SquareName(Position p) => $"{FileChar(p.Col)}{8 - p.Row}";
|
||||
|
||||
private static string PieceLetter(PieceType type) => type switch
|
||||
{
|
||||
PieceType.Knight => "N",
|
||||
PieceType.Bishop => "B",
|
||||
PieceType.Rook => "R",
|
||||
PieceType.Queen => "Q",
|
||||
PieceType.King => "K",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The repetition signature of a position: the first four FEN fields — piece placement,
|
||||
/// side to move, castling rights, and en-passant target. Move counters are excluded.
|
||||
/// </summary>
|
||||
private static string PositionKey(GameState gs)
|
||||
{
|
||||
var fields = gs.ToFen().Split(' ');
|
||||
return string.Join(' ', fields.Take(4));
|
||||
}
|
||||
|
||||
private static void PerformMove(GameState gs, ChessPiece piece, Position targetPos, MoveDto moveDto)
|
||||
{
|
||||
var oldPos = piece.Position;
|
||||
var captured = gs.Board[targetPos.Row, targetPos.Col];
|
||||
var isPawnMove = piece.Type == PieceType.Pawn; // captured before promotion can change Type
|
||||
|
||||
HandleEnPassantIfNeeded(gs, piece, targetPos, ref captured);
|
||||
|
||||
@@ -180,6 +276,9 @@ public class ChessService : IChessService
|
||||
|
||||
UpdateCastlingRights(gs, piece, oldPos);
|
||||
|
||||
// Reset the 50-move clock on captures and pawn moves (irreversible); else advance it.
|
||||
gs.HalfmoveClock = (isPawnMove || captured != null) ? 0 : gs.HalfmoveClock + 1;
|
||||
|
||||
gs.CurrentPlayer = gs.CurrentPlayer == PieceColor.White
|
||||
? PieceColor.Black
|
||||
: PieceColor.White;
|
||||
@@ -529,7 +628,14 @@ public class ChessService : IChessService
|
||||
var ep = gs.EnPassantTarget.Value;
|
||||
|
||||
if (ep.Row == forward1 && Math.Abs(ep.Col - startCol) == 1)
|
||||
moves.Add(ep);
|
||||
{
|
||||
// Verify there's an enemy pawn to capture
|
||||
int enemyPawnRow = piece.Color == PieceColor.White ? ep.Row + 1 : ep.Row - 1;
|
||||
var enemyPawn = gs.Board[enemyPawnRow, ep.Col];
|
||||
|
||||
if (enemyPawn != null && enemyPawn.Type == PieceType.Pawn && enemyPawn.Color != piece.Color)
|
||||
moves.Add(ep);
|
||||
}
|
||||
}
|
||||
|
||||
return moves;
|
||||
@@ -542,11 +648,7 @@ public class ChessService : IChessService
|
||||
=> GenerateSlidingMoves(gs, piece, [(1, 1), (1, -1), (-1, 1), (-1, -1)]);
|
||||
|
||||
private static List<Position> GenerateQueenMoves(GameState gs, ChessPiece piece)
|
||||
=> GenerateSlidingMoves(gs, piece,
|
||||
[
|
||||
(1, 0), (-1, 0), (0, 1), (0, -1),
|
||||
(1, 1), (1, -1), (-1, 1), (-1, -1)
|
||||
]);
|
||||
=> [..GenerateRookMoves(gs, piece), ..GenerateBishopMoves(gs, piece)];
|
||||
|
||||
private static List<Position> GenerateSlidingMoves(GameState gs, ChessPiece piece, (int dr, int dc)[] directions)
|
||||
{
|
||||
|
||||
@@ -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(), state.RepetitionHistory());
|
||||
|
||||
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,119 @@
|
||||
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, IReadOnlyList<string> historyFens) =>
|
||||
Task.Run(() => GetBestMove(fen, string.Join('\n', historyFens)));
|
||||
|
||||
private unsafe string GetBestMove(string fen, string history)
|
||||
{
|
||||
const int bufferLength = 16; // longest UCI move is 5 chars ("e7e8q") + NUL
|
||||
byte* buffer = stackalloc byte[bufferLength];
|
||||
|
||||
var added = false;
|
||||
|
||||
try
|
||||
{
|
||||
_handle.DangerousAddRef(ref added);
|
||||
var code = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, history, buffer, bufferLength);
|
||||
|
||||
if (code != 0)
|
||||
throw new InvalidOperationException($"Native chess engine failed to produce a move for FEN '{fen}' (engine_best_move returned {code}).");
|
||||
|
||||
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, string history, byte* outBuffer, int outLength);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||
internal static partial void engine_destroy(IntPtr engine);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using System.Text;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public static class PgnExporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Render a game as PGN: the standard seven-tag header plus the SAN movetext and result.
|
||||
/// </summary>
|
||||
public static string ToPgn(this GameState gs)
|
||||
{
|
||||
var result = ResultTag(gs);
|
||||
var name = gs.IsComputerVsComputer ? "Computer" : null;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("[Event \"JoshHeaps.Net Chess\"]");
|
||||
sb.AppendLine("[Site \"joshheaps.net\"]");
|
||||
sb.AppendLine($"[Date \"{DateTime.Now:yyyy.MM.dd}\"]");
|
||||
sb.AppendLine($"[White \"{name ?? "White"}\"]");
|
||||
sb.AppendLine($"[Black \"{name ?? "Black"}\"]");
|
||||
sb.AppendLine($"[Result \"{result}\"]");
|
||||
sb.AppendLine();
|
||||
|
||||
for (int i = 0; i < gs.SanHistory.Count; i++)
|
||||
{
|
||||
if (i % 2 == 0)
|
||||
sb.Append(i / 2 + 1).Append(". ");
|
||||
|
||||
sb.Append(gs.SanHistory[i]).Append(' ');
|
||||
}
|
||||
|
||||
sb.Append(result);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ResultTag(GameState gs)
|
||||
{
|
||||
if (gs.IsCheckmate)
|
||||
return gs.CurrentPlayer == PieceColor.White ? "0-1" : "1-0";
|
||||
|
||||
if (gs.IsStalemate || gs.IsThreefoldRepetition)
|
||||
return "1/2-1/2";
|
||||
|
||||
if (gs.IsForfeited)
|
||||
return gs.Winner == PieceColor.White ? "1-0" : "0-1";
|
||||
|
||||
return "*";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -77,8 +74,9 @@ public sealed class Stockfish : IAsyncDisposable
|
||||
WaitFor("readyok").GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task<string> GetBestMoveAsync(string fen)
|
||||
public async Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens)
|
||||
{
|
||||
// historyFens is unused: Stockfish tracks repetition from the position it's given.
|
||||
Send($"position fen {fen}");
|
||||
Send($"go depth {_skill}");
|
||||
string? best = null;
|
||||
@@ -109,145 +107,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,11 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Interfaces;
|
||||
|
||||
public interface IBlogService
|
||||
{
|
||||
Task<List<BlogPost>> GetAllPostsAsync();
|
||||
Task<BlogPost?> GetPostBySlugAsync(string slug);
|
||||
Task<List<BlogPost>> GetPostsByTagAsync(string tag);
|
||||
void ClearCache();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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>
|
||||
/// <param name="historyFens">
|
||||
/// Prior positions since the last irreversible move, oldest first, excluding the
|
||||
/// current one — lets the engine detect threefold/50-move draws the FEN can't carry.
|
||||
/// May be empty.
|
||||
/// </param>
|
||||
/// <returns>The selected move as a UCI string.</returns>
|
||||
Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,12 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"BlogApi": {
|
||||
"BaseUrl": "https://media.joshheaps.net",
|
||||
"InvalidateKey": "CHANGE_ME"
|
||||
},
|
||||
"ChessEngine": {
|
||||
"Engine": "Custom"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
overflow-x: hidden;
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.blog-index {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 4rem clamp(1.5rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.blog-title {
|
||||
color: var(--color-heading);
|
||||
font-size: clamp(1.75rem, 3vw, 2.25rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 2.5rem;
|
||||
}
|
||||
|
||||
.blog-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.blog-filter strong {
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
.blog-filter a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.blog-filter a:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.blog-empty {
|
||||
color: var(--color-text-subtle);
|
||||
font-style: italic;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.blog-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.blog-card {
|
||||
padding: 1.5rem 1.75rem;
|
||||
border-left: 2px solid var(--color-accent-border);
|
||||
background: var(--color-surface);
|
||||
border-radius: 0 8px 8px 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.blog-card:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.blog-card-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.blog-card-title {
|
||||
color: var(--color-heading-secondary);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.3rem;
|
||||
letter-spacing: -0.01em;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.blog-card-link:hover .blog-card-title {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.blog-card-date {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.75rem;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.blog-card-summary {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.blog-card-tags {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.blog-tag {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.blog-tag:hover {
|
||||
background: var(--color-surface-raised-hover);
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
overflow-x: hidden;
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.blog-post {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 4rem clamp(1.5rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.blog-back {
|
||||
color: var(--color-text-subtle);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.blog-back:hover {
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
.blog-post-header {
|
||||
margin: 2.5rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.blog-post-title {
|
||||
color: var(--color-heading);
|
||||
font-size: clamp(1.75rem, 3vw, 2.25rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 0.5rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.blog-post-date {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.8rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.blog-post-tags {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.blog-tag {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.blog-tag:hover {
|
||||
background: var(--color-surface-raised-hover);
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
/* ── Post Content ── */
|
||||
|
||||
.post-content {
|
||||
color: var(--color-text);
|
||||
line-height: 1.8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.post-content h1,
|
||||
.post-content h2,
|
||||
.post-content h3,
|
||||
.post-content h4 {
|
||||
color: var(--color-heading-secondary);
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.post-content h1 { font-size: 1.6rem; }
|
||||
.post-content h2 { font-size: 1.35rem; }
|
||||
.post-content h3 { font-size: 1.15rem; }
|
||||
|
||||
.post-content p {
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
.post-content a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.post-content a:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.post-content ul,
|
||||
.post-content ol {
|
||||
margin: 0 0 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.post-content li {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.post-content code {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-heading-secondary);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.post-content pre {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 1.25rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border) transparent;
|
||||
}
|
||||
|
||||
.post-content pre::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.post-content pre::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.post-content pre::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.post-content pre[class*="language-"] {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.post-content pre code {
|
||||
background: none;
|
||||
color: var(--prism-text);
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.post-content blockquote {
|
||||
border-left: 2px solid var(--color-accent-border);
|
||||
margin: 0 0 1.25rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-surface);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.post-content img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.post-content strong {
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
.post-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/* Prism.js Tomorrow Night theme (customized) */
|
||||
/* Original: https://github.com/PrismJS/prism/blob/master/themes/prism-tomorrow.css */
|
||||
|
||||
/* ============================================
|
||||
Theme colors — edit these to restyle
|
||||
============================================ */
|
||||
:root {
|
||||
--prism-background: #0a0a0a;
|
||||
--prism-text: #909090;
|
||||
--prism-comment: #555;
|
||||
--prism-punctuation: #707070;
|
||||
--prism-tag: #09ff00;
|
||||
--prism-function: #fff;
|
||||
--prism-boolean: #1ab815;
|
||||
--prism-number: #1ab815;
|
||||
--prism-property: #c8c8c8;
|
||||
--prism-class: #c8c8c8;
|
||||
--prism-keyword: #09ff00;
|
||||
--prism-string: #43c760;
|
||||
--prism-variable: #43c760;
|
||||
--prism-operator: #999;
|
||||
--prism-inserted: #09ff00;
|
||||
}
|
||||
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
color: var(--prism-text);
|
||||
background: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1em;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
line-height: 1.5;
|
||||
tab-size: 4;
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
pre[class*="language-"] {
|
||||
padding: 1em;
|
||||
margin: 0.5em 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:not(pre) > code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
background: var(--prism-background);
|
||||
}
|
||||
|
||||
:not(pre) > code[class*="language-"] {
|
||||
padding: 0.1em;
|
||||
border-radius: 0.3em;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Comments */
|
||||
.token.comment,
|
||||
.token.block-comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: var(--prism-comment);
|
||||
}
|
||||
|
||||
/* Punctuation */
|
||||
.token.punctuation {
|
||||
color: var(--prism-punctuation);
|
||||
}
|
||||
|
||||
/* Tags, attributes, deleted */
|
||||
.token.tag,
|
||||
.token.attr-name,
|
||||
.token.namespace,
|
||||
.token.deleted {
|
||||
color: var(--prism-tag);
|
||||
}
|
||||
|
||||
/* Function names */
|
||||
.token.function-name,
|
||||
.token.function {
|
||||
color: var(--prism-function);
|
||||
}
|
||||
|
||||
/* Booleans */
|
||||
.token.boolean {
|
||||
color: var(--prism-boolean);
|
||||
}
|
||||
|
||||
/* Numbers */
|
||||
.token.number {
|
||||
color: var(--prism-number);
|
||||
}
|
||||
|
||||
/* Properties */
|
||||
.token.property {
|
||||
color: var(--prism-property);
|
||||
}
|
||||
|
||||
/* Classes, constants, symbols */
|
||||
.token.class-name,
|
||||
.token.constant,
|
||||
.token.symbol {
|
||||
color: var(--prism-class);
|
||||
}
|
||||
|
||||
/* Keywords, selectors, builtins */
|
||||
.token.selector,
|
||||
.token.important,
|
||||
.token.atrule,
|
||||
.token.keyword,
|
||||
.token.builtin {
|
||||
color: var(--prism-keyword);
|
||||
}
|
||||
|
||||
/* Strings, chars, attr values, regex */
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.attr-value,
|
||||
.token.regex {
|
||||
color: var(--prism-string);
|
||||
}
|
||||
|
||||
/* Variables */
|
||||
.token.variable {
|
||||
color: var(--prism-variable);
|
||||
}
|
||||
|
||||
/* Operators, entities, URLs */
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url {
|
||||
color: var(--prism-operator);
|
||||
}
|
||||
|
||||
/* Inserted */
|
||||
.token.inserted {
|
||||
color: var(--prism-inserted);
|
||||
}
|
||||
|
||||
/* Formatting */
|
||||
.token.bold,
|
||||
.token.important {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
#chessBoard {
|
||||
#boardContainer {
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
margin: 2vw auto;
|
||||
}
|
||||
|
||||
#chessBoard {
|
||||
width: 60vw;
|
||||
height: 60vw;
|
||||
display: grid;
|
||||
@@ -159,3 +164,29 @@
|
||||
height: 50px;
|
||||
pointer-events: none; /* ensures img doesn't steal the click */
|
||||
}
|
||||
|
||||
.chessSquare .coordinate-label {
|
||||
position: absolute;
|
||||
font-size: 1.2vw;
|
||||
font-weight: bold;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chessSquare.light .coordinate-label {
|
||||
color: #656770;
|
||||
}
|
||||
|
||||
.chessSquare.dark .coordinate-label {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.chessSquare .row-label {
|
||||
top: 2px;
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
.chessSquare .col-label {
|
||||
bottom: 2px;
|
||||
right: 4px;
|
||||
}
|
||||
@@ -28,6 +28,28 @@
|
||||
margin: 2vh;
|
||||
}
|
||||
|
||||
#copyPgnBtn {
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border-radius: 30px;
|
||||
border: 0px;
|
||||
cursor: pointer;
|
||||
order: 1;
|
||||
padding: 2vh;
|
||||
margin: 2vh;
|
||||
}
|
||||
|
||||
#watchLink {
|
||||
color: #8cd5ed;
|
||||
text-decoration: none;
|
||||
margin: 2vh;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
#watchLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#chessContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
html, body {
|
||||
background-color: #2b2c30;
|
||||
color: #d6d6d6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#watchHeader {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem 0;
|
||||
}
|
||||
|
||||
#watchHeader h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#watchStatus {
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
#watchControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
#cpuDifficulty {
|
||||
background-color: #1e1e1e;
|
||||
color: #d6d6d6;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
#startCpuVsCpu {
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border: 0;
|
||||
border-radius: 30px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#startCpuVsCpu:hover {
|
||||
background-color: #a5e0f2;
|
||||
}
|
||||
|
||||
#backToPlay {
|
||||
color: #8cd5ed;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#backToPlay:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#gamesFeed {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gameCard {
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.4);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gameResult {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
color: #8cd5ed;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.copyPgnBtn {
|
||||
display: block;
|
||||
margin: 0.75rem auto 0;
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border: 0;
|
||||
border-radius: 30px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copyPgnBtn:hover {
|
||||
background-color: #a5e0f2;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.fullscreen-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gameCard.fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
cursor: default;
|
||||
background-color: #2b2c30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gameCard.fullscreen .miniBoard {
|
||||
width: min(90vh, 90vw);
|
||||
height: min(90vh, 90vw);
|
||||
}
|
||||
|
||||
.gameCard.fullscreen .backButton {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
background-color: #8cd5ed;
|
||||
color: #262626;
|
||||
border: 0;
|
||||
border-radius: 30px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gameCardHeader {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.miniBoard {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
grid-template-rows: repeat(8, 1fr);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* ── Blog Section ── */
|
||||
|
||||
#BlogBox {
|
||||
max-width: 1100px;
|
||||
margin: clamp(4rem, 8vw, 8rem) auto;
|
||||
padding: 0 clamp(1.5rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
#blogPostsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.blog-teaser-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-left: 2px solid var(--color-accent-border);
|
||||
background: var(--color-surface);
|
||||
border-radius: 0 8px 8px 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.blog-teaser-item:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.blog-teaser-title {
|
||||
font-size: clamp(1rem, 1.5vw, 1.15rem);
|
||||
font-weight: 600;
|
||||
color: var(--color-heading-secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.blog-teaser-title:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.blog-teaser-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.blog-teaser-summary {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0.25rem 0 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.blog-view-all {
|
||||
display: inline-block;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.blog-view-all:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
@@ -1,17 +1,25 @@
|
||||
#Contacts {
|
||||
margin-top: 20vw;
|
||||
/* ── Footer / Contacts ── */
|
||||
|
||||
#Contacts {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: clamp(4rem, 8vw, 6rem) 0 clamp(3rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
.social-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
height: auto;
|
||||
width: 3.5vw;
|
||||
margin-bottom: 5vw;
|
||||
margin-left: 3vw;
|
||||
fill: rgb(212, 212, 212);
|
||||
transition: fill 0.5s;
|
||||
width: clamp(1.25rem, 2.5vw, 1.75rem);
|
||||
fill: var(--color-text-subtle);
|
||||
transition: fill 0.2s;
|
||||
}
|
||||
|
||||
.social-icon:hover {
|
||||
fill: white;
|
||||
transition: fill 0.5s;
|
||||
.social-link:hover .social-icon {
|
||||
fill: var(--color-heading-secondary);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
.diagonal-section-left {
|
||||
position: absolute;
|
||||
clip-path: polygon(0 0, 85vw 0, 60vw 100%, 0 100%);
|
||||
background-color: rgb(104, 255, 0, 0.39);
|
||||
transition: clip-path 0.5s ease;
|
||||
}
|
||||
|
||||
.diagonal-section-left:has(.projectImage:hover) {
|
||||
clip-path: polygon(0 0, 87vw 0, 58vw 100%, 0 100%);
|
||||
transition: clip-path 0.5s ease;
|
||||
}
|
||||
|
||||
.projectImageLeft {
|
||||
width: 33vw;
|
||||
height: auto;
|
||||
margin: 3vw;
|
||||
}
|
||||
|
||||
.projectTextRight {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-left: 50vw;
|
||||
}
|
||||
|
||||
.projectHeaderRight {
|
||||
margin: 0;
|
||||
color: rgb(212, 212, 212);
|
||||
font-size: 4vw;
|
||||
}
|
||||
|
||||
.projectDescriptionRight {
|
||||
margin: 0;
|
||||
color: rgb(212, 212, 212);
|
||||
font-size: 2vw;
|
||||
width: 40vw;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
.diagonal-section-right {
|
||||
position: absolute;
|
||||
clip-path: polygon(40vw 0, 100% 0, 100% 100%, 15vw 100%);
|
||||
background-color: rgb(104, 255, 0, 0.39);
|
||||
transition: clip-path 0.5s ease;
|
||||
}
|
||||
|
||||
.diagonal-section-right:has(.projectImage:hover) {
|
||||
clip-path: polygon(42vw 0, 100% 0, 100% 100%, 13vw 100%);
|
||||
transition: clip-path 0.5s ease;
|
||||
}
|
||||
|
||||
.projectImageRight {
|
||||
width: 33vw;
|
||||
height: auto;
|
||||
margin: 3vw;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.projectTextLeft {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.projectHeaderLeft {
|
||||
margin: 0;
|
||||
color: rgb(212, 212, 212);
|
||||
font-size: 4vw;
|
||||
margin-left: 10vw;
|
||||
}
|
||||
|
||||
.projectDescriptionLeft {
|
||||
margin: 0;
|
||||
color: rgb(212, 212, 212);
|
||||
font-size: 2vw;
|
||||
width: 40vw;
|
||||
margin-left: 10vw;
|
||||
}
|
||||
@@ -1,29 +1,90 @@
|
||||
#ProjectsBox {
|
||||
/* ── Projects Section ── */
|
||||
|
||||
#ProjectsBox {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 clamp(1.5rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
grid-auto-rows: 1fr;
|
||||
gap: 20vw;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.projectContainer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* ── Card ── */
|
||||
|
||||
.project-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
transition: border-color 0.25s, transform 0.25s;
|
||||
}
|
||||
|
||||
.displayBox {
|
||||
width: 100vw;
|
||||
height: auto;
|
||||
.project-card:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
a.projectContainer {
|
||||
text-decoration: none;
|
||||
/* ── Image ── */
|
||||
|
||||
.project-image-link {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.projectImage {
|
||||
transition: width 0.5s ease;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
.projectImage:hover {
|
||||
width: 40vw;
|
||||
transition: width 0.5s ease;
|
||||
.project-card:hover .projectImage {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
/* ── Info ── */
|
||||
|
||||
.project-info {
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.project-description {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.65;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.project-link {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.project-link:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.projects-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.project-info {
|
||||
padding: 1rem 1.25rem 1.25rem;
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,134 @@
|
||||
body {
|
||||
margin: 0px;
|
||||
background-color: #2b2c30;
|
||||
color: #09ff00;
|
||||
overflow-x: hidden;
|
||||
cursor: default;
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
overflow-x: hidden;
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Navigation ── */
|
||||
|
||||
#Header {
|
||||
margin-top: 2vw;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem clamp(1.5rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
.headerOption {
|
||||
font: bold 2vw arial;
|
||||
display: inline;
|
||||
margin-left: 10vw;
|
||||
.site-name {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-heading);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: clamp(1.25rem, 3vw, 2.5rem);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-subtle);
|
||||
cursor: pointer;
|
||||
transition: color 0.5s ease, font 0.5s ease;
|
||||
transition: color 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.headerOption:hover {
|
||||
font: bold 2.3vw arial;
|
||||
transition: color 0.5s ease, font 0.5s ease;
|
||||
.nav-link:hover {
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
/* ── Hero / Welcome Message ── */
|
||||
|
||||
#Hero {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(4rem, 10vw, 8rem) clamp(1.5rem, 5vw, 4rem) clamp(4rem, 8vw, 6rem);
|
||||
}
|
||||
|
||||
#WelcomeMessage {
|
||||
font: 3vw arial;
|
||||
width: auto;
|
||||
height: 40vw;
|
||||
margin-top: 15vw;
|
||||
text-align: left;
|
||||
padding-left: 10vw;
|
||||
padding-right: 10vw;
|
||||
font-size: clamp(1.25rem, 2.2vw, 1.75rem);
|
||||
max-width: 720px;
|
||||
min-height: clamp(12rem, 28vw, 20rem);
|
||||
line-height: 1.7;
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ── Section Titles ── */
|
||||
|
||||
.section-title {
|
||||
font-size: clamp(1.3rem, 2.2vw, 1.75rem);
|
||||
font-weight: 700;
|
||||
color: var(--color-heading);
|
||||
margin: 0 0 clamp(1.5rem, 3vw, 2.5rem);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* ── Demos ── */
|
||||
|
||||
#DemosBox {
|
||||
margin: 10vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
width: 80%;
|
||||
max-width: 1100px;
|
||||
margin: clamp(4rem, 8vw, 8rem) auto;
|
||||
padding: 0 clamp(1.5rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
#DemoHeader {
|
||||
margin: 0;
|
||||
color: rgb(212, 212, 212);
|
||||
font-size: 4vw;
|
||||
#buttonWrapper {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.demoButton {
|
||||
margin-top: 5vw;
|
||||
margin-right: 2vw;
|
||||
padding-top: 0.5vw;
|
||||
padding-bottom: 0.5vw;
|
||||
padding: 0.875rem 1.25rem;
|
||||
text-align: center;
|
||||
border: none;
|
||||
border-radius: 5vw;
|
||||
width: 20vw;
|
||||
height: 8vw;
|
||||
font-size: 2vw;
|
||||
background-color: rgb(104, 255, 0, 0.39);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.demoButton:hover {
|
||||
background: var(--color-surface-hover);
|
||||
border-color: var(--color-border-hover);
|
||||
color: var(--color-heading-secondary);
|
||||
}
|
||||
|
||||
#MoreFiller {
|
||||
cursor: default;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
#MoreFiller:hover {
|
||||
background: var(--color-surface);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#Header {
|
||||
padding: 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
:root {
|
||||
/* Backgrounds */
|
||||
--color-bg: #0a0a0a;
|
||||
--color-surface: rgba(255, 255, 255, 0.02);
|
||||
--color-surface-hover: rgba(255, 255, 255, 0.04);
|
||||
--color-surface-raised: rgba(255, 255, 255, 0.05);
|
||||
--color-surface-raised-hover: rgba(255, 255, 255, 0.08);
|
||||
|
||||
/* Text */
|
||||
--color-heading: #fff;
|
||||
--color-heading-secondary: #e0e0e0;
|
||||
--color-text: #b0b0b0;
|
||||
--color-text-muted: #aaa;
|
||||
--color-text-subtle: #777;
|
||||
|
||||
/* Accent */
|
||||
--color-accent: #09ff00;
|
||||
--color-accent-hover: #5fff5f;
|
||||
|
||||
/* Borders */
|
||||
--color-border: rgba(255, 255, 255, 0.07);
|
||||
--color-border-hover: rgba(255, 255, 255, 0.15);
|
||||
--color-accent-border: rgba(9, 255, 0, 0.4);
|
||||
|
||||
/* Typography */
|
||||
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 275 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 132 KiB |
@@ -14,6 +14,20 @@ const ChessAPI = {
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
async getPgn(gameId) {
|
||||
const response = await fetch(`/api/chess/${gameId}/pgn`);
|
||||
if (!response.ok) throw new Error("PGN unavailable");
|
||||
return await response.text();
|
||||
},
|
||||
|
||||
async forfeit(gameId, playerId) {
|
||||
await fetch("/api/chess/forfeit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ GameId: gameId, PlayerId: playerId })
|
||||
});
|
||||
},
|
||||
|
||||
async getLegalMoves(pieceId) {
|
||||
const response = await fetch(`/api/chess/${GameState.currentGameId}/legalMoves/${pieceId}`);
|
||||
if (!response.ok) throw new Error("API failed");
|
||||
@@ -27,9 +41,14 @@ const ChessAPI = {
|
||||
body: JSON.stringify(moveDto)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = await response.text();
|
||||
throw new Error(message || "Invalid move or not your turn.");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "Invalid move or not your turn.");
|
||||
}
|
||||
|
||||
@@ -96,9 +115,13 @@ const ChessAPI = {
|
||||
} else if (moveResult.isStalemate) {
|
||||
alert("🤝 Stalemate!");
|
||||
gameOver = true;
|
||||
} else if (moveResult.isThreefoldRepetition) {
|
||||
alert("🤝 Draw by threefold repetition!");
|
||||
gameOver = true;
|
||||
}
|
||||
|
||||
if (gameOver) {
|
||||
showCopyPgn(GameState.currentGameId);
|
||||
await ChessSignalR.leaveGame();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const ChessBoard = {
|
||||
renderPieces(pieces) {
|
||||
this.clearAllSquares();
|
||||
this.renderCoordinateLabels();
|
||||
this.placePieces(pieces);
|
||||
this.setupSquareEventHandlers();
|
||||
this.highlightPreviousMove();
|
||||
@@ -106,6 +107,39 @@ const ChessBoard = {
|
||||
ChessAPI.handleMove(targetRow, targetCol);
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
renderCoordinateLabels() {
|
||||
const files = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
|
||||
const ranks = ['8', '7', '6', '5', '4', '3', '2', '1'];
|
||||
|
||||
// If player is black, reverse the coordinates
|
||||
if (!GameState.currentPlayerIsWhite) {
|
||||
files.reverse();
|
||||
ranks.reverse();
|
||||
}
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const square = document.getElementById(`square-${i}`);
|
||||
const row = Math.floor(i / 8);
|
||||
const col = i % 8;
|
||||
|
||||
// Add rank label (1-8) on the leftmost column
|
||||
if (col === 0) {
|
||||
const rankLabel = document.createElement('span');
|
||||
rankLabel.className = 'coordinate-label row-label';
|
||||
rankLabel.textContent = ranks[row];
|
||||
square.appendChild(rankLabel);
|
||||
}
|
||||
|
||||
// Add file label (a-h) on the bottom row
|
||||
if (row === 7) {
|
||||
const fileLabel = document.createElement('span');
|
||||
fileLabel.className = 'coordinate-label col-label';
|
||||
fileLabel.textContent = files[col];
|
||||
square.appendChild(fileLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ const ChessSignalR = {
|
||||
await this.handleMoveUpdate(gameId, moveDto, moveResultDto);
|
||||
});
|
||||
|
||||
this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => {
|
||||
await this.handleGameOver(gameId, winner, reason);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.connection.start();
|
||||
console.log("✅ SignalR connected");
|
||||
@@ -34,6 +38,18 @@ const ChessSignalR = {
|
||||
ChessAPI.alertGameStatusChange(moveResultDto);
|
||||
},
|
||||
|
||||
async handleGameOver(gameId, winner, reason) {
|
||||
if (gameId !== GameState.currentGameId) return;
|
||||
|
||||
const youWon = (winner === "White") === GameState.currentPlayerIsWhite;
|
||||
|
||||
if (reason === "forfeit")
|
||||
alert(youWon ? "🏳️ Your opponent forfeited — you win!" : "🏳️ You forfeited this game.");
|
||||
|
||||
showCopyPgn(gameId);
|
||||
await this.leaveGame();
|
||||
},
|
||||
|
||||
async notifyMoveMade(moveDto, moveResult) {
|
||||
if (this.connection) {
|
||||
await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult);
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
const Spectate = {
|
||||
connection: null,
|
||||
games: new Map(), // gameId -> { isVsComputer, isComputerVsComputer, result }
|
||||
pgns: new Map(), // gameId -> PGN text (prefetched when a game finishes)
|
||||
|
||||
pieceTypeNames: ["Pawn", "Rook", "Knight", "Bishop", "Queen", "King"],
|
||||
|
||||
async init() {
|
||||
this.connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl("/chessHub")
|
||||
.configureLogging(signalR.LogLevel.Warning)
|
||||
.build();
|
||||
|
||||
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto) =>
|
||||
this.handleMoveUpdate(gameId, moveDto));
|
||||
|
||||
this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId));
|
||||
|
||||
try {
|
||||
await this.connection.start();
|
||||
} catch (err) {
|
||||
console.error("❌ SignalR failed to start:", err);
|
||||
}
|
||||
|
||||
await this.refreshGames();
|
||||
setInterval(() => this.refreshGames(), 5000);
|
||||
},
|
||||
|
||||
async startCpuGame() {
|
||||
const difficulty = document.getElementById("cpuDifficulty").value;
|
||||
|
||||
try {
|
||||
await fetch(`/api/chess/watch/cpu/${difficulty}`);
|
||||
await this.refreshGames();
|
||||
} catch (err) {
|
||||
console.error("❌ Could not start CPU vs CPU game.", err);
|
||||
}
|
||||
},
|
||||
|
||||
async refreshGames() {
|
||||
let games;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/chess/active");
|
||||
games = await response.json();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIds = new Set(games.map(g => g.gameId));
|
||||
|
||||
for (const gameId of [...this.games.keys()])
|
||||
if (!activeIds.has(gameId))
|
||||
await this.removeGame(gameId);
|
||||
|
||||
for (const game of games)
|
||||
if (this.games.has(game.gameId))
|
||||
this.updateHeader(game.gameId, game.currentPlayer, game.moveCount, game.isCheck);
|
||||
else
|
||||
await this.addGame(game);
|
||||
|
||||
const status = document.getElementById("watchStatus");
|
||||
status.textContent = games.length === 0
|
||||
? "No games are being played right now."
|
||||
: `${games.length} game${games.length === 1 ? "" : "s"} in progress`;
|
||||
},
|
||||
|
||||
async addGame(game) {
|
||||
this.games.set(game.gameId, {
|
||||
isVsComputer: game.isVsComputer,
|
||||
isComputerVsComputer: game.isComputerVsComputer
|
||||
});
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "gameCard";
|
||||
card.id = `card-${game.gameId}`;
|
||||
card.onclick = () => this.enterFullscreen(game.gameId);
|
||||
|
||||
const back = document.createElement("button");
|
||||
back.className = "backButton";
|
||||
back.textContent = "← Back";
|
||||
back.onclick = (event) => this.exitFullscreen(game.gameId, event);
|
||||
card.appendChild(back);
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "gameCardHeader";
|
||||
header.id = `header-${game.gameId}`;
|
||||
header.textContent = this.headerText(this.games.get(game.gameId), game.currentPlayer, game.moveCount, game.isCheck);
|
||||
card.appendChild(header);
|
||||
|
||||
const board = document.createElement("div");
|
||||
board.className = "miniBoard";
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const square = document.createElement("div");
|
||||
square.id = `sq-${game.gameId}-${i}`;
|
||||
square.className = `chessSquare ${(i + Math.floor(i / 8)) % 2 === 0 ? "light" : "dark"}`;
|
||||
board.appendChild(square);
|
||||
}
|
||||
|
||||
card.appendChild(board);
|
||||
document.getElementById("gamesFeed").appendChild(card);
|
||||
|
||||
await this.connection.invoke("JoinWebsocketGroup", game.gameId).catch(() => { });
|
||||
await this.renderGame(game.gameId);
|
||||
},
|
||||
|
||||
async removeGame(gameId) {
|
||||
this.games.delete(gameId);
|
||||
this.pgns.delete(gameId);
|
||||
document.getElementById(`card-${gameId}`)?.remove();
|
||||
await this.connection.invoke("LeaveWebsocketGroup", gameId).catch(() => { });
|
||||
},
|
||||
|
||||
async renderGame(gameId) {
|
||||
const response = await fetch(`/api/chess/${gameId}`);
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const state = await response.json();
|
||||
const result = this.resultTextFromState(state);
|
||||
const stored = this.games.get(gameId);
|
||||
|
||||
if (stored) stored.result = result;
|
||||
|
||||
this.renderPieces(gameId, state.pieces);
|
||||
this.updateHeader(gameId, state.currentPlayer, state.moveHistory.length, state.isCheck);
|
||||
this.setResult(gameId, result);
|
||||
},
|
||||
|
||||
async handleMoveUpdate(gameId, moveDto) {
|
||||
if (!this.games.has(gameId)) return;
|
||||
|
||||
await this.renderGame(gameId);
|
||||
this.highlightMove(gameId, moveDto);
|
||||
},
|
||||
|
||||
renderPieces(gameId, pieces) {
|
||||
this.clearBoard(gameId);
|
||||
|
||||
pieces.forEach(piece => {
|
||||
const square = document.getElementById(`sq-${gameId}-${piece.row * 8 + piece.col}`);
|
||||
|
||||
if (!square) return;
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = this.pieceImageUrl(piece);
|
||||
img.alt = piece.type;
|
||||
img.className = "chessPiece";
|
||||
img.draggable = false;
|
||||
square.appendChild(img);
|
||||
});
|
||||
},
|
||||
|
||||
clearBoard(gameId) {
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const square = document.getElementById(`sq-${gameId}-${i}`);
|
||||
|
||||
if (!square) continue;
|
||||
|
||||
square.innerHTML = "";
|
||||
square.classList.remove("previous-start", "previous-end");
|
||||
}
|
||||
},
|
||||
|
||||
highlightMove(gameId, moveDto) {
|
||||
document.getElementById(`sq-${gameId}-${moveDto.sourceRow * 8 + moveDto.sourceCol}`)?.classList.add("previous-start");
|
||||
document.getElementById(`sq-${gameId}-${moveDto.targetRow * 8 + moveDto.targetCol}`)?.classList.add("previous-end");
|
||||
},
|
||||
|
||||
updateHeader(gameId, currentPlayer, moveCount, isCheck) {
|
||||
const stored = this.games.get(gameId);
|
||||
const header = document.getElementById(`header-${gameId}`);
|
||||
|
||||
if (stored && header)
|
||||
header.textContent = this.headerText(stored, currentPlayer, moveCount, isCheck);
|
||||
},
|
||||
|
||||
gameLabel(stored) {
|
||||
if (stored.isComputerVsComputer) return "CPU vs CPU";
|
||||
if (stored.isVsComputer) return "Vs CPU";
|
||||
return "Player vs Player";
|
||||
},
|
||||
|
||||
headerText(stored, currentPlayer, moveCount, isCheck) {
|
||||
if (stored.result)
|
||||
return `${this.gameLabel(stored)} · move ${moveCount} · final`;
|
||||
|
||||
const check = isCheck ? " • check" : "";
|
||||
return `${this.gameLabel(stored)} · move ${moveCount} · ${currentPlayer} to move${check}`;
|
||||
},
|
||||
|
||||
resultTextFromState(state) {
|
||||
if (state.isCheckmate)
|
||||
return `${state.currentPlayer === "White" ? "Black" : "White"} wins by checkmate`;
|
||||
if (state.isStalemate)
|
||||
return "Draw — stalemate";
|
||||
if (state.isThreefoldRepetition)
|
||||
return "Draw — threefold repetition";
|
||||
return null;
|
||||
},
|
||||
|
||||
setResult(gameId, text) {
|
||||
const card = document.getElementById(`card-${gameId}`);
|
||||
|
||||
if (!card) return;
|
||||
|
||||
let banner = card.querySelector(".gameResult");
|
||||
|
||||
if (!text) {
|
||||
banner?.remove();
|
||||
card.querySelector(".copyPgnBtn")?.remove();
|
||||
card.classList.remove("over");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!banner) {
|
||||
banner = document.createElement("div");
|
||||
banner.className = "gameResult";
|
||||
card.appendChild(banner);
|
||||
}
|
||||
|
||||
banner.textContent = text;
|
||||
card.classList.add("over");
|
||||
this.addCopyPgn(gameId, card);
|
||||
},
|
||||
|
||||
addCopyPgn(gameId, card) {
|
||||
if (card.querySelector(".copyPgnBtn")) return;
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "copyPgnBtn";
|
||||
btn.textContent = "Copy PGN";
|
||||
btn.onclick = (event) => { event.stopPropagation(); this.copyPgn(gameId); };
|
||||
card.appendChild(btn);
|
||||
|
||||
// Prefetch now (while the game is still in memory) so copy works during the
|
||||
// brief window before the finished game is cleaned up.
|
||||
fetch(`/api/chess/${gameId}/pgn`)
|
||||
.then(r => r.ok ? r.text() : null)
|
||||
.then(t => { if (t) this.pgns.set(gameId, t); })
|
||||
.catch(() => { });
|
||||
},
|
||||
|
||||
async copyPgn(gameId) {
|
||||
let pgn = this.pgns.get(gameId);
|
||||
|
||||
if (!pgn) {
|
||||
try {
|
||||
const r = await fetch(`/api/chess/${gameId}/pgn`);
|
||||
if (r.ok) pgn = await r.text();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (!pgn) {
|
||||
alert("PGN is no longer available for this game.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(pgn);
|
||||
alert("📋 PGN copied to clipboard!");
|
||||
} catch {
|
||||
alert("Couldn't access the clipboard. Here's the PGN:\n\n" + pgn);
|
||||
}
|
||||
},
|
||||
|
||||
enterFullscreen(gameId) {
|
||||
document.getElementById(`card-${gameId}`)?.classList.add("fullscreen");
|
||||
document.body.classList.add("fullscreen-open");
|
||||
},
|
||||
|
||||
exitFullscreen(gameId, event) {
|
||||
event?.stopPropagation();
|
||||
document.getElementById(`card-${gameId}`)?.classList.remove("fullscreen");
|
||||
document.body.classList.remove("fullscreen-open");
|
||||
},
|
||||
|
||||
pieceImageUrl(piece) {
|
||||
const color = piece.color === 0 ? "White" : "Black";
|
||||
return `/images/Chess Images/${color}${this.pieceTypeNames[piece.type]}.svg`;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("load", () => Spectate.init());
|
||||
|
||||
console.log("Spectate.js loaded");
|
||||
@@ -1,5 +1,52 @@
|
||||
let lastPgn = null;
|
||||
|
||||
async function showCopyPgn(gameId) {
|
||||
try {
|
||||
lastPgn = await ChessAPI.getPgn(gameId);
|
||||
const btn = document.getElementById("copyPgnBtn");
|
||||
if (btn) btn.style.display = "";
|
||||
} catch (err) {
|
||||
console.warn("Could not load PGN.", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPgn() {
|
||||
if (!lastPgn) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(lastPgn);
|
||||
alert("📋 PGN copied to clipboard!");
|
||||
} catch {
|
||||
alert("Couldn't access the clipboard. Here's the PGN:\n\n" + lastPgn);
|
||||
}
|
||||
}
|
||||
|
||||
function resetCopyPgn() {
|
||||
lastPgn = null;
|
||||
const btn = document.getElementById("copyPgnBtn");
|
||||
if (btn) btn.style.display = "none";
|
||||
}
|
||||
|
||||
window.copyPgn = copyPgn;
|
||||
window.showCopyPgn = showCopyPgn;
|
||||
|
||||
async function forfeitCurrentGame() {
|
||||
const gameId = ChessUtils.getCookie("chessGameId");
|
||||
const playerId = ChessUtils.getCookie("chessPlayerId");
|
||||
|
||||
if (!gameId || !playerId) return;
|
||||
|
||||
try {
|
||||
await ChessAPI.forfeit(gameId, playerId);
|
||||
} catch (err) {
|
||||
console.warn("Could not forfeit previous game.", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function startNewGame() {
|
||||
await ChessSignalR.stopConnection();
|
||||
await forfeitCurrentGame();
|
||||
resetCopyPgn();
|
||||
|
||||
try {
|
||||
const gameData = await ChessAPI.joinGame();
|
||||
@@ -25,6 +72,8 @@ async function startNewGame() {
|
||||
|
||||
async function startCPUGame() {
|
||||
await ChessSignalR.stopConnection();
|
||||
await forfeitCurrentGame();
|
||||
resetCopyPgn();
|
||||
|
||||
try {
|
||||
const difficulty = await ChessModals.promptDifficulty();
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
element.style.display = "none"; // Trigger reflow
|
||||
element.offsetHeight; // Force reflow
|
||||
element.style.display = ""; // Restore the original display
|
||||
typeText("#WelcomeMessage", "Hey, you found me...!?``````````````````````No that's stupid````````````````Just````uhhhh`````Hello! My name is Josh. I'm a software engineer, and I try to have a lot of fun with what I do. While I have your attention, why don't I tell you more about myself?");
|
||||
typeText("#WelcomeMessage", "Hey, you found me...!?``````````````````````No that's stupid...```````````````````Just...```````uhhhh...````````Hello! My name is Josh. I'm a software engineer, and I try to have a lot of fun with what I do. While I have your attention, why don't I tell you more about myself?");
|
||||
}
|
||||
@@ -12,18 +12,19 @@
|
||||
let intervalId;
|
||||
let index = 0;
|
||||
const punctuation = ['.', '!', '?'];
|
||||
const punctuationDict = {
|
||||
'.': 150,
|
||||
'!': 200,
|
||||
'?': 200,
|
||||
',': 100,
|
||||
'`': 25,
|
||||
};
|
||||
|
||||
let currentText = "";
|
||||
|
||||
function changeIntervalTime(element, text) {
|
||||
if (punctuation.includes(text.charAt(index))) {
|
||||
clearInterval(intervalId);
|
||||
simulateTyping(element, text, 200);
|
||||
}
|
||||
else {
|
||||
clearInterval(intervalId);
|
||||
simulateTyping(element, text);
|
||||
}
|
||||
clearInterval(intervalId);
|
||||
simulateTyping(element, text, punctuationDict[text.charAt(index)] ?? 50);
|
||||
}
|
||||
|
||||
function simulateTyping(element, text, speed = 50) {
|
||||
@@ -44,9 +45,9 @@ function simulateTyping(element, text, speed = 50) {
|
||||
|
||||
function showClickedContents(option) {
|
||||
if (option === 'projects') {
|
||||
document.querySelector("#ChessProject > div.diagonal-section-left").scrollIntoView({
|
||||
document.querySelector("#ProjectsBox").scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
block: "start",
|
||||
inline: "nearest"
|
||||
});
|
||||
}
|
||||
@@ -67,4 +68,11 @@ function showClickedContents(option) {
|
||||
inline: "nearest"
|
||||
});
|
||||
}
|
||||
else if (option === 'blog') {
|
||||
document.querySelector("#BlogBox").scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
inline: "nearest"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,115 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Times the search before vs after killer-move ordering.
|
||||
|
||||
.DESCRIPTION
|
||||
Compiles two standalone bench binaries from the current engine source — one with killer
|
||||
ordering disabled (the pre-killer baseline, via /DBENCH_DISABLE_KILLERS) and one with it
|
||||
on — then runs each benchmark position in its own process (cold transposition table) and
|
||||
reports wall-clock search time for both, plus the speedup. Timing is measured inside the
|
||||
engine around engine_best_move, so process startup isn't counted. Each position is run
|
||||
-Reps times and the fastest run is kept, to cut scheduling noise.
|
||||
|
||||
.PARAMETER Skill
|
||||
Search difficulty / max depth. Default 8.
|
||||
|
||||
.PARAMETER Positions
|
||||
Which position indices to run (0=kiwipete, 1=ruy, 2=sicilian). Default: all.
|
||||
|
||||
.PARAMETER Reps
|
||||
Runs per position per variant; the minimum time is reported. Default 3.
|
||||
|
||||
.PARAMETER ShowDepths
|
||||
Also print every per-depth line the engine emits.
|
||||
|
||||
.EXAMPLE
|
||||
.\bench.ps1
|
||||
.\bench.ps1 -Skill 9 -Reps 5 -Positions 0,2
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$Skill = 8,
|
||||
[int[]]$Positions,
|
||||
[int]$Reps = 3,
|
||||
[switch]$ShowDepths
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = $PSScriptRoot
|
||||
$src = Join-Path $root 'src'
|
||||
$inc = Join-Path $root 'include'
|
||||
$build = Join-Path $root 'build'
|
||||
if (-not (Test-Path $build)) { New-Item -ItemType Directory -Path $build | Out-Null }
|
||||
|
||||
# --- enter a VS dev shell so cl is on PATH ---
|
||||
$devShell = "D:\Program Files\Visual Studio 2026\Common7\Tools\Launch-VsDevShell.ps1"
|
||||
if (-not (Test-Path $devShell)) { throw "VS dev shell not found at $devShell" }
|
||||
& $devShell -Arch amd64 -HostArch amd64 -SkipAutomaticLocation | Out-Null
|
||||
|
||||
$engineSources = 'chess_engine.cpp','bitboard.cpp','zobrist.cpp','position.cpp','movegen.cpp','uci.cpp' |
|
||||
ForEach-Object { Join-Path $src $_ }
|
||||
$benchMain = Join-Path $root 'test\bench_main.cpp'
|
||||
|
||||
function Build-Variant([string]$exe, [string[]]$extraDefs) {
|
||||
$clArgs = @('/nologo','/std:c++17','/O2','/EHsc','/arch:AVX2','/DCHESS_ENGINE_BUILD','/DNDEBUG') +
|
||||
$extraDefs + @("/I$inc","/I$src", $benchMain) + $engineSources + @("/Fe:$exe", "/Fo:$build\")
|
||||
& cl @clArgs | Out-Null
|
||||
if (-not (Test-Path $exe)) { throw "compile failed: $exe" }
|
||||
}
|
||||
|
||||
$exeBefore = Join-Path $build 'bench_before.exe' # killers disabled (pre-killer baseline)
|
||||
$exeAfter = Join-Path $build 'bench_after.exe' # killers enabled (current)
|
||||
|
||||
Write-Host "Compiling both variants..." -ForegroundColor Cyan
|
||||
Build-Variant $exeBefore @('/DBENCH_DISABLE_KILLERS')
|
||||
Build-Variant $exeAfter @()
|
||||
|
||||
$nodePattern = '^depth (\d+) nodes (\d+) best (\S+) score (-?\d+)$'
|
||||
$timePattern = 'time_ms=(\d+)'
|
||||
|
||||
function Run-One([string]$exe, [int]$pos) {
|
||||
$nodes = 0; $best = '?'; $bestMs = [long]::MaxValue
|
||||
for ($r = 0; $r -lt $Reps; $r++) {
|
||||
$err = [System.IO.Path]::GetTempFileName()
|
||||
$out = [System.IO.Path]::GetTempFileName()
|
||||
Start-Process -FilePath $exe -ArgumentList $pos,$Skill -NoNewWindow -Wait `
|
||||
-RedirectStandardError $err -RedirectStandardOutput $out | Out-Null
|
||||
$lines = Get-Content $err
|
||||
Remove-Item $err,$out -Force -ErrorAction SilentlyContinue
|
||||
if ($ShowDepths) { $lines | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } }
|
||||
|
||||
$deepest = $lines | Select-String $nodePattern | Select-Object -Last 1
|
||||
if ($deepest) { $nodes = [long]$deepest.Matches.Groups[2].Value; $best = $deepest.Matches.Groups[3].Value }
|
||||
$tm = $lines | Select-String $timePattern | Select-Object -Last 1
|
||||
if ($tm) { $ms = [long]$tm.Matches.Groups[1].Value; if ($ms -lt $bestMs) { $bestMs = $ms } }
|
||||
}
|
||||
return [pscustomobject]@{ Nodes = $nodes; Best = $best; Ms = $bestMs }
|
||||
}
|
||||
|
||||
if (-not $Positions) { $Positions = 0,1,2 }
|
||||
$names = @('kiwipete','ruy','sicilian')
|
||||
$rows = @()
|
||||
|
||||
foreach ($i in $Positions) {
|
||||
$b = Run-One $exeBefore $i
|
||||
$a = Run-One $exeAfter $i
|
||||
$speedup = if ($a.Ms -gt 0) { [math]::Round($b.Ms / $a.Ms, 2) } else { 0 }
|
||||
$rows += [pscustomobject]@{
|
||||
Position = $names[$i]
|
||||
'ms (before)' = $b.Ms
|
||||
'ms (after)' = $a.Ms
|
||||
Speedup = "${speedup}x"
|
||||
'nodes before' = $b.Nodes
|
||||
'nodes after' = $a.Nodes
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Skill $Skill, best of $Reps runs (before = no killers, after = killers)" -ForegroundColor Cyan
|
||||
$rows | Format-Table -AutoSize
|
||||
|
||||
$tb = ($rows | Measure-Object -Property 'ms (before)' -Sum).Sum
|
||||
$ta = ($rows | Measure-Object -Property 'ms (after)' -Sum).Sum
|
||||
$tot = if ($ta -gt 0) { [math]::Round($tb / $ta, 2) } else { 0 }
|
||||
Write-Host ("TOTAL {0} ms -> {1} ms ({2}x faster)" -f $tb, $ta, $tot)
|
||||
@@ -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,85 @@
|
||||
/* 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.
|
||||
* history : optional null-terminated UTF-8 list of the prior positions since the
|
||||
* last irreversible move (capture/pawn move), one FEN per line, oldest
|
||||
* first, NOT including `fen`. Lets the engine detect threefold/50-move
|
||||
* draws that the FEN alone can't carry. May be NULL or empty.
|
||||
* out_buf : host-owned buffer the engine writes the UCI move into,
|
||||
* as a null-terminated ASCII string (e.g. "e2e4\0").
|
||||
* out_len : capacity of out_buf in bytes (host passes >= 8).
|
||||
* 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,
|
||||
const char* history,
|
||||
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,550 @@
|
||||
/* 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 <algorithm>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
#include <string>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
|
||||
/* Search score constants. Scores are side-to-move-relative (negamax): positive is
|
||||
* good for whoever is to move. MATE_BOUND is the threshold above which a score is a
|
||||
* "mate in N" rather than a positional eval; INF is the window sentinel (kept above
|
||||
* MATE so negating it can never hit signed-overflow UB the way INT_MIN would). */
|
||||
static constexpr int MATE = 200000;
|
||||
static constexpr int MATE_BOUND = MATE - 1000;
|
||||
static constexpr int INF = 1000000;
|
||||
|
||||
/* Bound kind stored in a TT entry. LOWER = a fail-high (true score >= stored),
|
||||
* UPPER = a fail-low (true score <= stored), EXACT = fully resolved. */
|
||||
enum class Bound : uint8_t { NONE, EXACT, LOWER, UPPER };
|
||||
|
||||
/* One shared, process-wide transposition table backs every game (every engine
|
||||
* handle), so analysis persists and is reused across games. It is lock-free: each
|
||||
* slot is two 64-bit words — `data` (the packed payload) and `xorKey` (the Zobrist
|
||||
* key XOR-ed with `data`). A reader recovers the key as `xorKey ^ data`; if two
|
||||
* concurrent searches tore the pair, the recovered key won't match and the read is
|
||||
* treated as a miss — never a wrong-but-trusted entry (Hyatt's lockless hashing). */
|
||||
struct TTEntry {
|
||||
std::atomic<uint64_t> xorKey{0};
|
||||
std::atomic<uint64_t> data{0};
|
||||
};
|
||||
|
||||
struct TranspositionTable {
|
||||
std::unique_ptr<TTEntry[]> entries;
|
||||
size_t mask = 0; /* count - 1; count is a power of two */
|
||||
};
|
||||
|
||||
static TranspositionTable g_tt;
|
||||
static constexpr size_t TT_MEGABYTES = 256;
|
||||
|
||||
/* Pack/unpack the 64-bit payload: score(32) | move(16) | depth(8) | bound(8). A stored
|
||||
* entry always has depth >= 1 and a non-NONE bound, so a real entry never packs to 0 —
|
||||
* letting data == 0 mean "empty slot". */
|
||||
static uint64_t tt_pack(int score, chess::Move move, int depth, Bound bound) {
|
||||
return static_cast<uint64_t>(static_cast<uint32_t>(score))
|
||||
| (static_cast<uint64_t>(move.data) << 32)
|
||||
| (static_cast<uint64_t>(static_cast<uint8_t>(depth)) << 48)
|
||||
| (static_cast<uint64_t>(static_cast<uint8_t>(bound)) << 56);
|
||||
}
|
||||
static int tt_score(uint64_t d) { return static_cast<int32_t>(static_cast<uint32_t>(d & 0xFFFFFFFFu)); }
|
||||
static chess::Move tt_move (uint64_t d) { return chess::Move(static_cast<uint16_t>(d >> 32)); }
|
||||
static int tt_depth(uint64_t d) { return static_cast<int>(static_cast<uint8_t>(d >> 48)); }
|
||||
static Bound tt_bound(uint64_t d) { return static_cast<Bound>(static_cast<uint8_t>(d >> 56)); }
|
||||
|
||||
/* Internal engine state. One ChessEngine = one game. The table is NOT here: it is the
|
||||
* shared g_tt above. */
|
||||
struct ChessEngine {
|
||||
int skill = 20; /* 1..20 from the UI; controls search depth */
|
||||
};
|
||||
|
||||
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
|
||||
* quiescence yet, so deep fixed-depth runs get expensive quickly. */
|
||||
static int depth_for_skill(int skill) {
|
||||
return skill; /* skill N -> N plies */
|
||||
}
|
||||
|
||||
static size_t floor_pow2(size_t n) {
|
||||
size_t p = 1;
|
||||
while ((p << 1) != 0 && (p << 1) <= n) p <<= 1;
|
||||
return p;
|
||||
}
|
||||
|
||||
/* Allocate the shared table exactly once, to the largest power-of-two entry count that
|
||||
* fits in TT_MEGABYTES. Power-of-two count lets indexing use `key & mask`. Thread-safe:
|
||||
* call_once guards the first concurrent engine_create. Entries start zeroed (empty). */
|
||||
static void ensure_tt() {
|
||||
static std::once_flag once;
|
||||
std::call_once(once, [] {
|
||||
size_t count = floor_pow2((TT_MEGABYTES << 20) / sizeof(TTEntry));
|
||||
if (count < 1) count = 1;
|
||||
g_tt.entries = std::make_unique<TTEntry[]>(count);
|
||||
g_tt.mask = count - 1;
|
||||
});
|
||||
}
|
||||
|
||||
/* 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 += (6 - squaresToPromotion) * 100; // 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 castleIncentive(const chess::Position& pos, chess::Color c) {
|
||||
if (!pos.pieces(chess::QUEEN))
|
||||
return 0;
|
||||
chess::Square k = pos.king_square(c);
|
||||
bool castled = (c == chess::WHITE) ? (k == chess::G1 || k == chess::C1)
|
||||
: (k == chess::G8 || k == chess::C8);
|
||||
|
||||
return castled ? 600 : 0;
|
||||
}
|
||||
|
||||
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;
|
||||
case chess::KING: score = castleIncentive(pos, c); break;
|
||||
default: return 0;
|
||||
}
|
||||
|
||||
score += center_multiplier(s);
|
||||
|
||||
if (pc != chess::B_PAWN && pc != chess::W_PAWN)
|
||||
score += piece_mobility(pos, s, pc, c) * 25;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* evaluate() is white-positive (absolute). Negamax needs it relative to the side to
|
||||
* move, so flip the sign when black is to move. */
|
||||
static int evaluate_stm(const chess::Position& pos, bool whiteToMove) {
|
||||
int s = evaluate(pos);
|
||||
return whiteToMove ? s : -s;
|
||||
}
|
||||
|
||||
/* Mate scores are "mate in N from THIS node", so they must be re-anchored to the
|
||||
* probing node's ply when crossing the TT (store adds ply, retrieve subtracts it).
|
||||
* Non-mate scores pass through untouched. */
|
||||
static int score_to_tt(int s, int ply) { return s >= MATE_BOUND ? s + ply : s <= -MATE_BOUND ? s - ply : s; }
|
||||
static int score_from_tt(int s, int ply) { return s >= MATE_BOUND ? s - ply : s <= -MATE_BOUND ? s + ply : s; }
|
||||
|
||||
static int piece_value(chess::PieceType pt) {
|
||||
switch (pt) {
|
||||
case chess::PAWN: return 100;
|
||||
case chess::KNIGHT: return 320;
|
||||
case chess::BISHOP: return 330;
|
||||
case chess::ROOK: return 500;
|
||||
case chess::QUEEN: return 900;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Heuristic for searching the most promising moves first, which makes alpha-beta prune far
|
||||
* more. Bands, highest first: the TT best move, then captures by MVV-LVA (most valuable
|
||||
* victim, least valuable attacker), then the two killer moves for this ply (quiet moves that
|
||||
* cut a sibling), then the remaining quiet moves. `killers` points at this ply's two-entry
|
||||
* slot; `scoreChecks` gates the expensive gives_check term to near-leaf nodes. */
|
||||
static int order_score(chess::Position& pos, chess::Move m, chess::Move ttMove,
|
||||
const chess::Move* killers, bool scoreChecks) {
|
||||
if (m == ttMove)
|
||||
return 2000000; /* dwarfs any capture/killer/check score below */
|
||||
|
||||
int score = 0;
|
||||
|
||||
if (scoreChecks && pos.gives_check(m))
|
||||
score += 1000;
|
||||
|
||||
chess::Piece victim = pos.piece_on(m.to());
|
||||
#ifdef BENCH_DISABLE_KILLERS
|
||||
/* Benchmark A/B only (defined by bench.ps1): the pre-killer ordering — captures by
|
||||
* MVV-LVA above quiet moves, no killer band — so the script can time the killer speedup. */
|
||||
(void)killers;
|
||||
if (victim != chess::NO_PIECE)
|
||||
score += 100 + 10 * piece_value(chess::type_of(victim))
|
||||
- piece_value(chess::type_of(pos.piece_on(m.from())));
|
||||
else if (m.type() == chess::EN_PASSANT)
|
||||
score += 100 + 10 * piece_value(chess::PAWN);
|
||||
#else
|
||||
if (victim != chess::NO_PIECE)
|
||||
score += 100000 + 10 * piece_value(chess::type_of(victim))
|
||||
- piece_value(chess::type_of(pos.piece_on(m.from())));
|
||||
else if (m.type() == chess::EN_PASSANT)
|
||||
score += 100000 + 10 * piece_value(chess::PAWN);
|
||||
else if (m == killers[0])
|
||||
score += 90000; /* quiet move that beta-cut a sibling at this ply */
|
||||
else if (m == killers[1])
|
||||
score += 80000;
|
||||
#endif
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/* Sort the move list in place, best-scoring first. Scores are computed once up
|
||||
* front so gives_check isn't re-evaluated on every comparison. ttMove may be
|
||||
* MOVE_NONE, in which case no move matches it and ordering falls back to captures. */
|
||||
static void order_moves(chess::Position& pos, chess::MoveList& moves, chess::Move ttMove,
|
||||
const chess::Move* killers, bool scoreChecks) {
|
||||
struct ScoredMove { int score; chess::Move move; };
|
||||
ScoredMove scored[256];
|
||||
|
||||
for (int i = 0; i < moves.size(); i++)
|
||||
scored[i] = { order_score(pos, moves.moves[i], ttMove, killers, scoreChecks), moves.moves[i] };
|
||||
|
||||
std::sort(scored, scored + moves.size(),
|
||||
[](const ScoredMove& a, const ScoredMove& b) { return a.score > b.score; });
|
||||
|
||||
for (int i = 0; i < moves.size(); i++)
|
||||
moves.moves[i] = scored[i].move;
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
|
||||
CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) {
|
||||
ensure_initialized();
|
||||
ensure_tt();
|
||||
auto* e = new (std::nothrow) ChessEngine();
|
||||
if (!e) return nullptr;
|
||||
e->skill = parse_skill(options, e->skill);
|
||||
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 */
|
||||
}
|
||||
|
||||
/* Per-search scratch, threaded through the recursion. Kept off global scope so two engine
|
||||
* handles can search concurrently without sharing node counts or killer tables. killers[ply]
|
||||
* holds up to two quiet moves that recently caused a beta cutoff at that ply; trying them
|
||||
* early (right after captures) prunes far more — the quiet-move ordering the search otherwise
|
||||
* lacks. */
|
||||
static constexpr int MAX_PLY = 128; /* ply never exceeds maxDepth (<= 20) */
|
||||
|
||||
struct SearchContext {
|
||||
uint64_t nodes = 0;
|
||||
chess::Move killers[MAX_PLY][2] = {}; /* [ply][slot]; MOVE_NONE until filled */
|
||||
};
|
||||
|
||||
/* Negamax alpha-beta over the shared transposition table. `maxDepth` is the searching
|
||||
* bot's difficulty (its root depth); `depth` is remaining depth (draft); `ply` is
|
||||
* distance from the root (mate scoring only). Scores are side-to-move-relative.
|
||||
* Fail-soft: returns the true best found even outside [alpha, beta]. */
|
||||
static int negamax(chess::Position& pos, int maxDepth, int depth, int ply,
|
||||
int alpha, int beta, bool whiteToMove, SearchContext& ctx) {
|
||||
ctx.nodes++;
|
||||
|
||||
/* A draw is 0 even at the search horizon, and the TT key doesn't encode repetition
|
||||
* history, so this must come before both the leaf eval and any TT probe. */
|
||||
if (ply > 0 && pos.is_draw())
|
||||
return 0;
|
||||
|
||||
if (depth <= 0)
|
||||
return evaluate_stm(pos, whiteToMove);
|
||||
|
||||
const uint64_t key = pos.key();
|
||||
TTEntry& slot = g_tt.entries[key & g_tt.mask];
|
||||
const uint64_t data = slot.data.load(std::memory_order_relaxed);
|
||||
const uint64_t xkey = slot.xorKey.load(std::memory_order_relaxed);
|
||||
|
||||
chess::Move ttMove = chess::MOVE_NONE;
|
||||
|
||||
if (data != 0 && (xkey ^ data) == key) { /* lockless: XOR check rejects torn reads */
|
||||
ttMove = tt_move(data); /* always reusable for ordering */
|
||||
int edepth = tt_depth(data);
|
||||
Bound b = tt_bound(data);
|
||||
|
||||
/* Trust the score only if it was searched deep enough for this node AND no deeper
|
||||
* than this bot's own strength — so a weak bot can't borrow a stronger game's
|
||||
* deeper analysis (it still gets the move for ordering, which can't leak strength). */
|
||||
if (edepth >= depth && edepth <= maxDepth) {
|
||||
int s = score_from_tt(tt_score(data), ply);
|
||||
if (b == Bound::EXACT) return s;
|
||||
if (b == Bound::LOWER && s >= beta) return s;
|
||||
if (b == Bound::UPPER && s <= alpha) return s;
|
||||
}
|
||||
}
|
||||
|
||||
chess::MoveList moves;
|
||||
pos.generate_legal(moves);
|
||||
|
||||
if (moves.size() == 0)
|
||||
return pos.is_draw() ? 0 : -MATE + ply; /* checkmate against side to move */
|
||||
|
||||
order_moves(pos, moves, ttMove, ctx.killers[ply], depth <= 2);
|
||||
|
||||
const int alphaOrig = alpha;
|
||||
int best = -INF;
|
||||
chess::Move bestMove = chess::MOVE_NONE;
|
||||
|
||||
for (int i = 0; i < moves.size(); i++) {
|
||||
chess::Move move = moves.moves[i];
|
||||
pos.do_move(move);
|
||||
int score = -negamax(pos, maxDepth, depth - 1, ply + 1, -beta, -alpha, !whiteToMove, ctx);
|
||||
pos.undo_move(move);
|
||||
|
||||
if (score > best) {
|
||||
best = score;
|
||||
bestMove = move;
|
||||
}
|
||||
if (best > alpha)
|
||||
alpha = best;
|
||||
if (best >= beta) {
|
||||
/* A quiet move good enough to fail high here is a strong candidate in sibling
|
||||
* lines at this ply — remember it as a killer. pos is back to pre-move state
|
||||
* after undo_move, so piece_on(to) still flags a capture correctly. */
|
||||
bool isCapture = pos.piece_on(move.to()) != chess::NO_PIECE
|
||||
|| move.type() == chess::EN_PASSANT;
|
||||
if (!isCapture && ply < MAX_PLY && ctx.killers[ply][0] != move) {
|
||||
ctx.killers[ply][1] = ctx.killers[ply][0];
|
||||
ctx.killers[ply][0] = move;
|
||||
}
|
||||
break; /* fail-high cutoff */
|
||||
}
|
||||
}
|
||||
|
||||
Bound flag = best <= alphaOrig ? Bound::UPPER
|
||||
: best >= beta ? Bound::LOWER
|
||||
: Bound::EXACT;
|
||||
|
||||
/* Depth-preferred replacement: keep the deepest analysis of each slot. The stored
|
||||
* payload is written before the xorKey so any concurrent reader that catches a
|
||||
* half-update fails the XOR check and treats it as a miss. */
|
||||
int storedDepth = (data == 0) ? -1 : tt_depth(data);
|
||||
if (depth >= storedDepth) {
|
||||
uint64_t packed = tt_pack(score_to_tt(best, ply), bestMove, depth, flag);
|
||||
slot.data.store(packed, std::memory_order_relaxed);
|
||||
slot.xorKey.store(key ^ packed, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
|
||||
const char* fen,
|
||||
const char* history,
|
||||
char* out_buf,
|
||||
int out_len) {
|
||||
if (!engine) return CHESS_ERR_NULL_HANDLE;
|
||||
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;
|
||||
|
||||
/* Seed the prior positions (one FEN per line) so is_draw() sees repetitions and
|
||||
* the 50-move count that the current FEN alone can't express. */
|
||||
if (history && *history) {
|
||||
std::vector<uint64_t> priorKeys;
|
||||
const char* p = history;
|
||||
while (*p) {
|
||||
const char* nl = std::strchr(p, '\n');
|
||||
size_t len = nl ? static_cast<size_t>(nl - p) : std::strlen(p);
|
||||
if (len > 0)
|
||||
priorKeys.push_back(chess::Position::from_fen(std::string(p, len)).key());
|
||||
if (!nl) break;
|
||||
p = nl + 1;
|
||||
}
|
||||
if (!priorKeys.empty())
|
||||
pos.seed_history(priorKeys.data(), static_cast<int>(priorKeys.size()));
|
||||
}
|
||||
|
||||
chess::MoveList moves;
|
||||
pos.generate_legal(moves);
|
||||
if (moves.size() == 0)
|
||||
return CHESS_ERR_NO_MOVE;
|
||||
|
||||
SearchContext ctx;
|
||||
int maxDepth = depth_for_skill(engine->skill);
|
||||
chess::Move bestMove = moves.moves[0]; /* guaranteed-legal fallback */
|
||||
|
||||
/* Iterative deepening: each depth seeds the next depth's move ordering (via the
|
||||
* previous best move and the TT it filled), which makes the deeper search prune
|
||||
* far harder than searching to maxDepth cold. */
|
||||
for (int d = 1; d <= maxDepth; d++) {
|
||||
int alpha = -INF, beta = INF;
|
||||
chess::Move iterBest = bestMove;
|
||||
int iterScore = -INF;
|
||||
|
||||
order_moves(pos, moves, iterBest, ctx.killers[0], true);
|
||||
|
||||
for (int i = 0; i < moves.size(); i++) {
|
||||
chess::Move move = moves.moves[i];
|
||||
pos.do_move(move);
|
||||
int score = -negamax(pos, maxDepth, d - 1, 1, -beta, -alpha, !whiteToMove, ctx);
|
||||
pos.undo_move(move);
|
||||
|
||||
if (score > iterScore) {
|
||||
iterScore = score;
|
||||
iterBest = move;
|
||||
}
|
||||
if (score > alpha)
|
||||
alpha = score;
|
||||
}
|
||||
|
||||
bestMove = iterBest; /* commit only a fully completed iteration */
|
||||
|
||||
std::fprintf(stderr, "depth %d nodes %llu best %s score %d\n",
|
||||
d, static_cast<unsigned long long>(ctx.nodes),
|
||||
chess::move_to_uci(iterBest).c_str(), iterScore);
|
||||
}
|
||||
|
||||
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,359 @@
|
||||
#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
|
||||
}
|
||||
|
||||
void Position::seed_history(const uint64_t* priorKeys, int count) {
|
||||
if (count <= 0) return;
|
||||
if (count > 1000) count = 1000; // leave headroom in repKeys for search plies
|
||||
|
||||
uint64_t current = zkey; // from_fen placed this at repKeys[0]
|
||||
for (int i = 0; i < count; ++i)
|
||||
repKeys[i] = priorKeys[i];
|
||||
repKeys[count] = current;
|
||||
repCount = count + 1;
|
||||
rule50 = count; // == half-moves since the last irreversible move
|
||||
}
|
||||
|
||||
bool Position::is_draw() const {
|
||||
if (rule50 >= 100) return true;
|
||||
if (insufficient_material()) return true;
|
||||
|
||||
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,92 @@
|
||||
// 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);
|
||||
|
||||
// Seed prior-position keys (oldest first, excluding the current position) so
|
||||
// is_draw() can see game history the FEN doesn't carry. Call once, right after
|
||||
// from_fen and before any do_move.
|
||||
void seed_history(const uint64_t* priorKeys, int count);
|
||||
|
||||
// --- freebies ---
|
||||
uint64_t key() const { return zkey; }
|
||||
bool is_draw() const; // 50-move + threefold + insufficient material
|
||||
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,64 @@
|
||||
/* Search benchmark harness. Drives engine_best_move on a fixed set of tactical positions
|
||||
* and lets the engine print its per-depth node counts (to stderr). Run ONE position per
|
||||
* process so each search starts with a cold transposition table — the shared TT is global
|
||||
* and would otherwise carry over between positions and skew the counts. bench.ps1 loops the
|
||||
* indices for you and tabulates the deepest line per position.
|
||||
*
|
||||
* bench [index] [skill]
|
||||
* index : position to run (0-based). Omit to run them all in this one process.
|
||||
* skill : search difficulty / max depth (default 8). */
|
||||
#include "chess_engine.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace {
|
||||
|
||||
struct Position { const char* name; const char* fen; };
|
||||
|
||||
const Position kPositions[] = {
|
||||
{ "kiwipete", "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1" },
|
||||
{ "ruy", "r1bqkbnr/pppp1ppp/2n5/1B2p3/4P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 0 1" },
|
||||
{ "sicilian", "2rq1rk1/pp1bppbp/2np1np1/8/3NP3/2N1BP2/PPPQ2PP/2KR1B1R w - - 0 1" },
|
||||
};
|
||||
const int kPositionCount = static_cast<int>(sizeof(kPositions) / sizeof(kPositions[0]));
|
||||
|
||||
void run(int index, const char* options) {
|
||||
EngineHandle engine = engine_create(options);
|
||||
if (!engine) {
|
||||
std::fprintf(stderr, "engine_create failed\n");
|
||||
return;
|
||||
}
|
||||
|
||||
char move[16];
|
||||
std::fprintf(stderr, "### %d %s\n", index, kPositions[index].name);
|
||||
|
||||
auto start = std::chrono::steady_clock::now();
|
||||
int rc = engine_best_move(engine, kPositions[index].fen, "", move, sizeof(move));
|
||||
auto elapsed = std::chrono::steady_clock::now() - start;
|
||||
long long ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count();
|
||||
|
||||
std::fprintf(stderr, "rc=%d best=%s time_ms=%lld\n", rc, move, ms);
|
||||
|
||||
engine_destroy(engine);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
int index = argc > 1 ? std::atoi(argv[1]) : -1;
|
||||
int skill = argc > 2 ? std::atoi(argv[2]) : 8;
|
||||
|
||||
char options[32];
|
||||
std::snprintf(options, sizeof(options), "skill=%d", skill);
|
||||
|
||||
if (index >= 0 && index < kPositionCount) {
|
||||
run(index, options);
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (int i = 0; i < kPositionCount; i++)
|
||||
run(i, options);
|
||||
return 0;
|
||||
}
|
||||
@@ -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