Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7562ded92a | ||
|
|
392b9ec212 | ||
|
|
769c7819e0 | ||
|
|
c048529654 | ||
|
|
e8eb90a085 | ||
|
|
fc167ae2b0 | ||
|
|
088f05e0d2 | ||
|
|
04dcae7993 | ||
|
|
f956f4bffa | ||
|
|
a53b0c2e43 | ||
|
|
edc4d70678 | ||
|
|
3bfcbaa190 | ||
|
|
59b318b75c | ||
|
|
e5e0c9cc48 | ||
|
|
3947bd0daf | ||
|
|
b99525ae51 | ||
|
|
9da7b1986b | ||
|
|
a0b8c2602a | ||
|
|
7604798e68 | ||
|
|
02163a4fc4 | ||
|
|
1680d13b05 | ||
|
|
a1e2ad9c2d | ||
|
|
26b4c0e59b | ||
|
|
aeef320cb4 | ||
|
|
d71e485611 | ||
|
|
d09a067377 | ||
|
|
acbee436bb | ||
|
|
d452ee414b | ||
|
|
f09ca60315 | ||
|
|
743c395e11 | ||
|
|
62e146648c | ||
|
|
6402ef268c | ||
|
|
c92c46e6d4 | ||
|
|
e8a3bfe432 | ||
|
|
8b78fd589c | ||
|
|
be07f7504e | ||
|
|
7bfbae7301 | ||
|
|
b651683e63 | ||
|
|
d87cea572f | ||
|
|
5345427e0c | ||
|
|
76b054ee9d | ||
|
|
2db95cec34 | ||
|
|
9b69489c4e | ||
|
|
fc2a2e122b | ||
|
|
c3280aa6d3 | ||
|
|
d2ae34f530 | ||
|
|
7fcfb270a1 | ||
|
|
3dc1bc452e | ||
|
|
4884c32fc8 |
@@ -0,0 +1,97 @@
|
|||||||
|
# .gitea/workflows/deploy.yml
|
||||||
|
name: Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "master" ]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-deploy:
|
||||||
|
# Gitea's runner images are lean, so the toolchain is installed below rather
|
||||||
|
# than assumed. The glibc that libchess_engine.so links against comes from
|
||||||
|
# this image, not from the runner host — keep it matched to the prod server.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install toolchain
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
cmake build-essential rsync openssh-client
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj
|
||||||
|
|
||||||
|
- name: Build native chess engine (libchess_engine.so)
|
||||||
|
run: |
|
||||||
|
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
|
||||||
|
cmake --build native/chess_engine/build
|
||||||
|
cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/
|
||||||
|
|
||||||
|
# Publishing the project rather than the solution keeps chess_engine.vcxproj
|
||||||
|
# (Windows-only MSBuild C++ targets) and the UiTests assemblies out of it.
|
||||||
|
- name: Publish
|
||||||
|
run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
|
||||||
|
|
||||||
|
- name: Verify payload
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -f publish/Resources/libchess_engine.so \
|
||||||
|
|| { echo "libchess_engine.so missing from publish output"; exit 1; }
|
||||||
|
test -x publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt \
|
||||||
|
|| { echo "stockfish is not executable"; exit 1; }
|
||||||
|
|
||||||
|
- name: Prepare SSH
|
||||||
|
env:
|
||||||
|
SSH_KEY: ${{ secrets.SSH_KEY }}
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PORT="${SSH_PORT:-22}"
|
||||||
|
install -m 700 -d ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
ssh-keyscan -p "$PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
# --exclude chess-data: never let --delete remove the learned-engine training
|
||||||
|
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
|
||||||
|
- name: Rsync to server
|
||||||
|
env:
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
TARGET_DIR: ${{ secrets.TARGET_DIR }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PORT="${SSH_PORT:-22}"
|
||||||
|
rsync -az --delete --exclude 'chess-data' \
|
||||||
|
-e "ssh -p $PORT -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes" \
|
||||||
|
publish/ "$SSH_USER@$SSH_HOST:$TARGET_DIR/"
|
||||||
|
|
||||||
|
- name: Reload and restart service
|
||||||
|
env:
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
SERVICE_NAME: ${{ secrets.SERVICE_NAME }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PORT="${SSH_PORT:-22}"
|
||||||
|
ssh -p "$PORT" -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
|
||||||
|
"sudo systemctl daemon-reload \
|
||||||
|
&& sudo systemctl restart '$SERVICE_NAME' \
|
||||||
|
&& systemctl --no-pager status '$SERVICE_NAME' --lines=0"
|
||||||
@@ -14,7 +14,9 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -24,9 +26,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
dotnet-version: '8.0.x' # adjust if needed
|
dotnet-version: '8.0.x' # adjust if needed
|
||||||
- name: Restore
|
- 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
|
- name: Publish
|
||||||
run: dotnet publish -c Release -o ./publish
|
run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -45,6 +54,12 @@ jobs:
|
|||||||
name: site-publish
|
name: site-publish
|
||||||
path: publish
|
path: publish
|
||||||
|
|
||||||
|
# GitHub artifacts don't preserve the Unix executable bit, so Stockfish (the only file
|
||||||
|
# the app spawns as a subprocess) arrives non-executable. Restore 755 here; rsync -a then
|
||||||
|
# carries it to the server, where the service user can run it regardless of file owner.
|
||||||
|
- name: Restore Stockfish executable bit
|
||||||
|
run: chmod 755 publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt
|
||||||
|
|
||||||
- name: Prepare SSH
|
- name: Prepare SSH
|
||||||
run: |
|
run: |
|
||||||
install -m 700 -d ~/.ssh
|
install -m 700 -d ~/.ssh
|
||||||
@@ -54,7 +69,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Rsync to server
|
- name: Rsync to server
|
||||||
run: |
|
run: |
|
||||||
rsync -az --delete -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \
|
# --exclude chess-data: never let --delete remove the learned-engine training
|
||||||
|
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
|
||||||
|
rsync -az --delete --exclude 'chess-data' -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \
|
||||||
publish/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.TARGET_DIR }}/
|
publish/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.TARGET_DIR }}/
|
||||||
|
|
||||||
- name: Reload and restart service
|
- name: Reload and restart service
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
dotnet-version: 8.0.x
|
dotnet-version: 8.0.x
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
run: dotnet restore
|
run: dotnet restore JoshHeaps.Net.slnf
|
||||||
- name: Build
|
- 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
|
dotnet-version: 8.0.x
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
run: dotnet restore
|
run: dotnet restore JoshHeaps.Net.slnf
|
||||||
|
|
||||||
- name: Build solution
|
- name: Build solution
|
||||||
run: dotnet build --no-restore
|
run: dotnet build JoshHeaps.Net.slnf --no-restore
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
##
|
##
|
||||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||||
|
|
||||||
|
# Learned chess engine weights (runtime training output, not source)
|
||||||
|
chess-data/
|
||||||
|
|
||||||
# User-specific files
|
# User-specific files
|
||||||
*.rsuser
|
*.rsuser
|
||||||
*.suo
|
*.suo
|
||||||
@@ -361,3 +364,6 @@ MigrationBackup/
|
|||||||
|
|
||||||
# Fody - auto-generated XML schema
|
# Fody - auto-generated XML schema
|
||||||
FodyWeavers.xsd
|
FodyWeavers.xsd
|
||||||
|
|
||||||
|
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
|
||||||
|
native/**/build/
|
||||||
+36
-2
@@ -1,26 +1,60 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 17
|
# Visual Studio Version 18
|
||||||
VisualStudioVersion = 17.9.34728.123
|
VisualStudioVersion = 18.6.11822.322 stable
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.Net\JoshHeaps.Net.csproj", "{9F0182CC-470F-4D1A-99F5-348D7921751E}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.Net\JoshHeaps.Net.csproj", "{9F0182CC-470F-4D1A-99F5-348D7921751E}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net.UiTests", "JoshHeaps.Net.UiTests\JoshHeaps.Net.UiTests.csproj", "{360264F4-8292-4EB3-B67D-98376C13438B}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net.UiTests", "JoshHeaps.Net.UiTests\JoshHeaps.Net.UiTests.csproj", "{360264F4-8292-4EB3-B67D-98376C13438B}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "chess_engine", "native\chess_engine\chess_engine\chess_engine.vcxproj", "{2579BBBC-1830-4342-BC10-0A4182DC84C7}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{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|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.ActiveCfg = Release|Any CPU
|
||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"solution": {
|
||||||
|
"path": "JoshHeaps.Net.sln",
|
||||||
|
"projects": [
|
||||||
|
"JoshHeaps.Net\\JoshHeaps.Net.csproj",
|
||||||
|
"JoshHeaps.Net.UiTests\\JoshHeaps.Net.UiTests.csproj"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
using JoshHeaps.Net.Hubs;
|
using JoshHeaps.Net.Hubs;
|
||||||
using JoshHeaps.Net.Models;
|
using JoshHeaps.Net.Models;
|
||||||
|
using JoshHeaps.Net.Services.Implementations;
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Controllers;
|
namespace JoshHeaps.Net.Controllers;
|
||||||
|
|
||||||
@@ -11,16 +11,15 @@ namespace JoshHeaps.Net.Controllers;
|
|||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class ChessController(
|
public class ChessController(
|
||||||
IChessService chessService,
|
IChessService chessService,
|
||||||
IHubContext<ChessHub> chessHub,
|
IBackgroundTaskQueue queue,
|
||||||
IBackgroundTaskQueue queue) : ControllerBase
|
IChessEngineFactory engineFactory,
|
||||||
|
IComputerMoveOrchestrator orchestrator,
|
||||||
|
ILearnedWeightsStore weightsStore,
|
||||||
|
IGameStore gameStore,
|
||||||
|
ISelfPlayCoordinator selfPlay,
|
||||||
|
AutoTrainingSettings autoTraining,
|
||||||
|
IHubContext<ChessHub> chessHub) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Store of ongoing games.
|
|
||||||
/// </summary>
|
|
||||||
private static readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
|
||||||
private static readonly ConcurrentDictionary<Guid, Task> _gameRemovalTasks = [];
|
|
||||||
private static readonly ConcurrentDictionary<Guid, CancellationTokenSource> _gameRemovalCancellationTokens = [];
|
|
||||||
|
|
||||||
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||||
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
|
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
|
||||||
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
|
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
|
||||||
@@ -30,38 +29,45 @@ public class ChessController(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("new")]
|
[HttpGet("new")]
|
||||||
[HttpGet("new/{difficulty}")]
|
[HttpGet("new/{difficulty}")]
|
||||||
public ActionResult CreateGame(int difficulty = 20)
|
public ActionResult CreateGame(int difficulty = 20, string color = "random")
|
||||||
{
|
{
|
||||||
var gameState = chessService.CreateNewGame();
|
var gameState = chessService.CreateNewGame();
|
||||||
_games[gameState.GameId] = gameState;
|
gameStore.Add(gameState);
|
||||||
|
|
||||||
gameState.IsVsComputer = true;
|
gameState.IsVsComputer = true;
|
||||||
gameState.WhiteJoined = true;
|
gameState.WhiteJoined = true;
|
||||||
gameState.BlackJoined = true;
|
gameState.BlackJoined = true;
|
||||||
Guid playerId = Guid.NewGuid();
|
Guid playerId = Guid.NewGuid();
|
||||||
Guid computerId = Guid.NewGuid();
|
Guid computerId = Guid.NewGuid();
|
||||||
var isWhite = Random.Shared.Next(2) == 0;
|
var isWhite = color.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"white" => true,
|
||||||
|
"black" => false,
|
||||||
|
_ => Random.Shared.Next(2) == 0,
|
||||||
|
};
|
||||||
|
|
||||||
gameState.Computer = new(difficulty);
|
var computer = engineFactory.Create(difficulty);
|
||||||
|
|
||||||
if (isWhite)
|
if (isWhite)
|
||||||
{
|
{
|
||||||
gameState.WhitePlayerId = playerId;
|
gameState.WhitePlayerId = playerId;
|
||||||
gameState.BlackPlayerId = computerId;
|
gameState.BlackPlayerId = computerId;
|
||||||
|
gameState.BlackComputer = computer;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
gameState.WhitePlayerId = computerId;
|
gameState.WhitePlayerId = computerId;
|
||||||
gameState.BlackPlayerId = playerId;
|
gameState.BlackPlayerId = playerId;
|
||||||
|
gameState.WhiteComputer = computer;
|
||||||
queue.Queue(async () =>
|
queue.Queue(async () =>
|
||||||
{
|
{
|
||||||
// Give user's browser time to connect to signalR and such.
|
// Give user's browser time to connect to signalR and such.
|
||||||
await Task.Delay(TimeSpan.FromSeconds(1));
|
await Task.Delay(TimeSpan.FromSeconds(1));
|
||||||
await gameState.Computer.MakeMove(gameState, chessHub, chessService);
|
await orchestrator.PlayAsync(gameState);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -71,6 +77,55 @@ public class ChessController(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a computer-vs-computer game and auto-play it move by move, broadcasting each
|
||||||
|
/// move so it can be watched on the spectator page. Each side's engine and skill can be
|
||||||
|
/// chosen independently; when the learned engine plays, the game also trains it.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("watch/cpu")]
|
||||||
|
[HttpGet("watch/cpu/{difficulty}")]
|
||||||
|
public ActionResult CreateSelfPlayGame(
|
||||||
|
int difficulty = 4,
|
||||||
|
string whiteEngine = "custom",
|
||||||
|
string blackEngine = "custom",
|
||||||
|
int? whiteSkill = null,
|
||||||
|
int? blackSkill = null)
|
||||||
|
{
|
||||||
|
var config = new SelfPlayConfig(
|
||||||
|
ParseEngineKind(whiteEngine), whiteSkill ?? difficulty,
|
||||||
|
ParseEngineKind(blackEngine), blackSkill ?? difficulty);
|
||||||
|
|
||||||
|
var (gameId, _) = selfPlay.StartGame(config);
|
||||||
|
|
||||||
|
return Ok(new { GameId = gameId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChessEngineKind ParseEngineKind(string value) => value.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"stockfish" => ChessEngineKind.Stockfish,
|
||||||
|
"customlearned" or "learned" => ChessEngineKind.CustomLearned,
|
||||||
|
_ => ChessEngineKind.Custom
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current number of background auto-training games (and the allowed maximum). Auto-training
|
||||||
|
/// itself runs only outside Development; this reflects the target the service is keeping.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("autotrain")]
|
||||||
|
public ActionResult GetAutoTrain() =>
|
||||||
|
Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set how many auto-training games run concurrently (clamped to 0..max; 0 pauses training).
|
||||||
|
/// Takes effect live — the background service tops up or drains toward the new count.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("autotrain")]
|
||||||
|
public ActionResult SetAutoTrain([FromQuery] int count)
|
||||||
|
{
|
||||||
|
autoTraining.GameCount = count; // clamped inside the setter
|
||||||
|
return Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Joins the "pool" of chess players.
|
/// Joins the "pool" of chess players.
|
||||||
/// Test code expects to receive a GUID for the player
|
/// Test code expects to receive a GUID for the player
|
||||||
@@ -80,12 +135,12 @@ public class ChessController(
|
|||||||
public ActionResult JoinGame()
|
public ActionResult JoinGame()
|
||||||
{
|
{
|
||||||
Console.WriteLine("joining game");
|
Console.WriteLine("joining game");
|
||||||
GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen);
|
GameState? gameState = gameStore.All.FirstOrDefault(g => g.IsOpen);
|
||||||
|
|
||||||
if (gameState == null)
|
if (gameState == null)
|
||||||
{
|
{
|
||||||
gameState = chessService.CreateNewGame();
|
gameState = chessService.CreateNewGame();
|
||||||
_games[gameState.GameId] = gameState;
|
gameStore.Add(gameState);
|
||||||
}
|
}
|
||||||
|
|
||||||
Guid playerId = Guid.NewGuid();
|
Guid playerId = Guid.NewGuid();
|
||||||
@@ -103,7 +158,7 @@ public class ChessController(
|
|||||||
isWhite = false;
|
isWhite = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -113,41 +168,62 @@ 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 = gameStore.All
|
||||||
|
// 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,
|
||||||
|
WhiteEngine = g.WhiteEngineKind.ToString(),
|
||||||
|
BlackEngine = g.BlackEngineKind.ToString(),
|
||||||
|
CurrentPlayer = g.CurrentPlayer.ToString(),
|
||||||
|
MoveCount = g.MoveHistory.Count,
|
||||||
|
g.IsCheck
|
||||||
|
})
|
||||||
|
.OrderByDescending(g => g.MoveCount);
|
||||||
|
|
||||||
|
return Ok(activeGames);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The learned engine's piece-square bonus table, for the weights-visualization page:
|
||||||
|
/// one 64-entry array per piece type (Pawn..King), white-relative (A1=0 .. H8=63).
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("weights")]
|
||||||
|
public ActionResult GetLearnedWeights()
|
||||||
|
{
|
||||||
|
var names = new[] { "Pawn", "Knight", "Bishop", "Rook", "Queen", "King" };
|
||||||
|
var featureNames = new[] { "Mobility N", "Mobility B", "Mobility R", "Mobility Q", "Passed", "Isolated", "Doubled", "King safety" };
|
||||||
|
|
||||||
|
var snapshot = weightsStore.Snapshot();
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
mg = snapshot.Mg.Select((squares, i) => new { name = names[i], squares }),
|
||||||
|
eg = snapshot.Eg.Select((squares, i) => new { name = names[i], squares }),
|
||||||
|
features = snapshot.Features.Select((value, i) => new { name = featureNames[i], value })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get the state of an existing game by ID.
|
/// Get the state of an existing game by ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("{gameId}")]
|
[HttpGet("{gameId}")]
|
||||||
public ActionResult GetGameState(Guid gameId)
|
public ActionResult GetGameState(Guid gameId)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(gameId, out var gameState))
|
if (!gameStore.TryGet(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
var response = new
|
return Ok(gameState.ToDto());
|
||||||
{
|
|
||||||
gameState.GameId,
|
|
||||||
CurrentPlayer = gameState.CurrentPlayer.ToString(),
|
|
||||||
gameState.IsCheck,
|
|
||||||
gameState.IsCheckmate,
|
|
||||||
gameState.IsStalemate,
|
|
||||||
EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null,
|
|
||||||
gameState.WhiteCanCastleKingside,
|
|
||||||
gameState.WhiteCanCastleQueenside,
|
|
||||||
gameState.BlackCanCastleKingside,
|
|
||||||
gameState.BlackCanCastleQueenside,
|
|
||||||
Pieces = gameState.Pieces
|
|
||||||
.Where(p => p.Position.Row >= 0)
|
|
||||||
.Select(p => new {
|
|
||||||
p.Id,
|
|
||||||
p.Type,
|
|
||||||
p.Color,
|
|
||||||
p.Position.Row,
|
|
||||||
p.Position.Col,
|
|
||||||
p.HasMoved
|
|
||||||
}),
|
|
||||||
gameState.MoveHistory
|
|
||||||
};
|
|
||||||
|
|
||||||
return Ok(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -155,9 +231,9 @@ public class ChessController(
|
|||||||
/// The test passes a JSON body with a MoveDto.
|
/// The test passes a JSON body with a MoveDto.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("move")]
|
[HttpPost("move")]
|
||||||
public ActionResult MakeMove([FromBody] MoveDto moveDto)
|
public async Task<ActionResult> MakeMove([FromBody] MoveDto moveDto)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(moveDto.GameId, out var gameState))
|
if (!gameStore.TryGet(moveDto.GameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
// Check if player is authorized to move
|
// Check if player is authorized to move
|
||||||
@@ -181,17 +257,73 @@ public class ChessController(
|
|||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
return BadRequest(result);
|
return BadRequest(result);
|
||||||
|
|
||||||
if (result.IsCheckmate || result.IsStalemate)
|
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
|
||||||
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
|
||||||
|
if (isGameOver)
|
||||||
|
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
|
||||||
else if (gameState.IsVsComputer)
|
else if (gameState.IsVsComputer)
|
||||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||||
else
|
else
|
||||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
||||||
|
|
||||||
if (gameState.IsVsComputer && gameState.Computer is not null)
|
var state = gameState.ToDto();
|
||||||
queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService));
|
|
||||||
|
|
||||||
return Ok(result);
|
// Broadcast the move (with the full resulting state) to everyone watching this
|
||||||
|
// game. The mover also receives this echo but drops it via the version guard,
|
||||||
|
// since it already rendered the same state from this response.
|
||||||
|
await chessHub.Clients.Group(gameState.GameId.ToString())
|
||||||
|
.SendAsync("ReceiveMoveUpdate", gameState.GameId.ToString(), moveDto, result, state);
|
||||||
|
|
||||||
|
var sideToMoveEngine = gameState.CurrentPlayer == PieceColor.White
|
||||||
|
? gameState.WhiteComputer
|
||||||
|
: gameState.BlackComputer;
|
||||||
|
|
||||||
|
if (!isGameOver && gameState.IsVsComputer && sideToMoveEngine is not null)
|
||||||
|
queue.Queue(() => orchestrator.PlayAsync(gameState));
|
||||||
|
|
||||||
|
return Ok(new { result, state });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 (!gameStore.TryGet(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");
|
||||||
|
|
||||||
|
gameStore.ScheduleRemove(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 (!gameStore.TryGet(gameId, out var gameState))
|
||||||
|
return NotFound("Game not found");
|
||||||
|
|
||||||
|
return Content(gameState.ToPgn(), "application/x-chess-pgn");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -200,7 +332,7 @@ public class ChessController(
|
|||||||
[HttpGet("{gameId}/legalMoves/{pieceId}")]
|
[HttpGet("{gameId}/legalMoves/{pieceId}")]
|
||||||
public ActionResult GetLegalMoves(Guid gameId, string pieceId)
|
public ActionResult GetLegalMoves(Guid gameId, string pieceId)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(gameId, out var gameState))
|
if (!gameStore.TryGet(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
|
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
|
||||||
@@ -214,7 +346,7 @@ public class ChessController(
|
|||||||
[HttpGet("{gameId}/legalMoves")]
|
[HttpGet("{gameId}/legalMoves")]
|
||||||
public ActionResult GetAllLegalMoves(Guid gameId)
|
public ActionResult GetAllLegalMoves(Guid gameId)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(gameId, out var gameState))
|
if (!gameStore.TryGet(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
var allMoves = chessService.GetAllLegalMoves(gameState)
|
var allMoves = chessService.GetAllLegalMoves(gameState)
|
||||||
@@ -226,39 +358,4 @@ public class ChessController(
|
|||||||
|
|
||||||
return Ok(allMoves);
|
return Ok(allMoves);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
|
|
||||||
{
|
|
||||||
if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts))
|
|
||||||
{
|
|
||||||
oldCts.Cancel();
|
|
||||||
oldCts.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
var cts = new CancellationTokenSource();
|
|
||||||
_gameRemovalCancellationTokens[id] = cts;
|
|
||||||
|
|
||||||
_gameRemovalTasks[id] = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.Delay(delay, cts.Token);
|
|
||||||
|
|
||||||
if (_games.TryGetValue(id, out var game) && game.Computer is not null)
|
|
||||||
await game.Computer.DisposeAsync();
|
|
||||||
|
|
||||||
_games.Remove(id, out _);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) { }
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (_gameRemovalCancellationTokens.TryGetValue(id, out var currentCts) && currentCts == cts)
|
|
||||||
{
|
|
||||||
_gameRemovalCancellationTokens.TryRemove(id, out _);
|
|
||||||
}
|
|
||||||
|
|
||||||
cts.Dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using JoshHeaps.Net.Models;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Hubs;
|
namespace JoshHeaps.Net.Hubs;
|
||||||
|
|
||||||
@@ -11,11 +10,6 @@ public class ChessHub : Hub
|
|||||||
await Groups.AddToGroupAsync(Context.ConnectionId, gameId);
|
await Groups.AddToGroupAsync(Context.ConnectionId, gameId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MoveMade(string gameId, MoveDto moveDto, MoveResultDto moveResult)
|
|
||||||
{
|
|
||||||
await Clients.OthersInGroup(gameId).SendAsync("ReceiveMoveUpdate", gameId, moveDto, moveResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task LeaveWebsocketGroup(string gameId)
|
public async Task LeaveWebsocketGroup(string gameId)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"❌ Leaving group {gameId}");
|
Console.WriteLine($"❌ Leaving group {gameId}");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UserSecretsId>53ed685c-bdff-4306-8cc2-9fbe55c85713</UserSecretsId>
|
<UserSecretsId>53ed685c-bdff-4306-8cc2-9fbe55c85713</UserSecretsId>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -28,4 +29,16 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.7" />
|
||||||
</ItemGroup>
|
</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>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace JoshHeaps.Net.Models;
|
||||||
|
|
||||||
|
public class ForfeitDto
|
||||||
|
{
|
||||||
|
public Guid GameId { get; set; }
|
||||||
|
public Guid PlayerId { get; set; }
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using JoshHeaps.Net.Services.Implementations;
|
using JoshHeaps.Net.Services.Implementations;
|
||||||
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Models;
|
namespace JoshHeaps.Net.Models;
|
||||||
|
|
||||||
@@ -28,10 +29,25 @@ public class GameState
|
|||||||
public bool IsCheck { get; set; }
|
public bool IsCheck { get; set; }
|
||||||
public bool IsCheckmate { get; set; }
|
public bool IsCheckmate { get; set; }
|
||||||
public bool IsStalemate { 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
|
// Keep a history of moves if desired
|
||||||
public List<string> MoveHistory { get; set; }
|
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).
|
// A list of all pieces to quickly reference them (optional but convenient).
|
||||||
// Alternatively, you can iterate the Board array.
|
// Alternatively, you can iterate the Board array.
|
||||||
public List<ChessPiece> Pieces { get; set; }
|
public List<ChessPiece> Pieces { get; set; }
|
||||||
@@ -42,8 +58,22 @@ public class GameState
|
|||||||
public Guid BlackPlayerId { get; set; }
|
public Guid BlackPlayerId { get; set; }
|
||||||
|
|
||||||
public bool IsVsComputer { get; set; } = false;
|
public bool IsVsComputer { get; set; } = false;
|
||||||
|
public bool IsComputerVsComputer { get; set; } = false;
|
||||||
|
|
||||||
public Stockfish? Computer { get; set; }
|
// The engine playing each side (null for a human). In a human-vs-computer game only the
|
||||||
|
// computer's side is set; the orchestrator picks the engine for whoever is to move.
|
||||||
|
public IChessEngine? WhiteComputer { get; set; }
|
||||||
|
public IChessEngine? BlackComputer { get; set; }
|
||||||
|
|
||||||
|
// Which engine implementation each side uses — lets game-over handling know which side(s)
|
||||||
|
// are the learning engine, and lets spectators see who is playing.
|
||||||
|
public ChessEngineKind WhiteEngineKind { get; set; }
|
||||||
|
public ChessEngineKind BlackEngineKind { get; set; }
|
||||||
|
|
||||||
|
// Native per-game training accumulator (nint.Zero when this game isn't training the
|
||||||
|
// learned engine). The engine records each played position into it and applies the
|
||||||
|
// result on game over.
|
||||||
|
public nint Trainer { get; set; }
|
||||||
|
|
||||||
// optional: convenience
|
// optional: convenience
|
||||||
public bool IsOpen => !WhiteJoined || !BlackJoined;
|
public bool IsOpen => !WhiteJoined || !BlackJoined;
|
||||||
@@ -54,5 +84,7 @@ public class GameState
|
|||||||
Board = new ChessPiece[8, 8];
|
Board = new ChessPiece[8, 8];
|
||||||
Pieces = [];
|
Pieces = [];
|
||||||
MoveHistory = [];
|
MoveHistory = [];
|
||||||
|
PositionHistory = [];
|
||||||
|
SanHistory = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single piece as sent to the client. Captured pieces keep their original
|
||||||
|
/// type and color; their position is meaningless and omitted.
|
||||||
|
/// </summary>
|
||||||
|
public record ChessPieceDto(string Id, PieceType Type, PieceColor Color, int Row, int Col, bool HasMoved);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The full board state pushed to clients. The same shape is returned by the
|
||||||
|
/// state endpoint, the move endpoint, and every SignalR move broadcast, so the
|
||||||
|
/// client always renders from one authoritative payload instead of re-fetching.
|
||||||
|
/// </summary>
|
||||||
|
public record GameStateDto(
|
||||||
|
Guid GameId,
|
||||||
|
string CurrentPlayer,
|
||||||
|
bool IsCheck,
|
||||||
|
bool IsCheckmate,
|
||||||
|
bool IsStalemate,
|
||||||
|
bool IsThreefoldRepetition,
|
||||||
|
string? EnPassantTarget,
|
||||||
|
bool WhiteCanCastleKingside,
|
||||||
|
bool WhiteCanCastleQueenside,
|
||||||
|
bool BlackCanCastleKingside,
|
||||||
|
bool BlackCanCastleQueenside,
|
||||||
|
IReadOnlyList<ChessPieceDto> Pieces,
|
||||||
|
IReadOnlyList<ChessPieceDto> CapturedPieces,
|
||||||
|
IReadOnlyList<string> MoveHistory,
|
||||||
|
// Moves in standard algebraic notation, for the move-list panel.
|
||||||
|
IReadOnlyList<string> SanHistory,
|
||||||
|
// Monotonic ply counter the client uses to drop stale or echoed updates.
|
||||||
|
int Version);
|
||||||
|
|
||||||
|
public static class GameStateMapper
|
||||||
|
{
|
||||||
|
public static GameStateDto ToDto(this GameState gameState) => new(
|
||||||
|
gameState.GameId,
|
||||||
|
gameState.CurrentPlayer.ToString(),
|
||||||
|
gameState.IsCheck,
|
||||||
|
gameState.IsCheckmate,
|
||||||
|
gameState.IsStalemate,
|
||||||
|
gameState.IsThreefoldRepetition,
|
||||||
|
gameState.EnPassantTarget?.ToString(),
|
||||||
|
gameState.WhiteCanCastleKingside,
|
||||||
|
gameState.WhiteCanCastleQueenside,
|
||||||
|
gameState.BlackCanCastleKingside,
|
||||||
|
gameState.BlackCanCastleQueenside,
|
||||||
|
gameState.Pieces
|
||||||
|
.Where(p => p.Position.Row >= 0)
|
||||||
|
.Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved))
|
||||||
|
.ToList(),
|
||||||
|
gameState.Pieces
|
||||||
|
.Where(p => p.Position.Row < 0)
|
||||||
|
.Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved))
|
||||||
|
.ToList(),
|
||||||
|
gameState.MoveHistory,
|
||||||
|
gameState.SanHistory,
|
||||||
|
gameState.MoveHistory.Count);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace JoshHeaps.Net.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A copy of the learned engine's weights for display: midgame and endgame piece-square
|
||||||
|
/// tables (one 64-entry array per piece, in canonical Pawn..King order, white-relative
|
||||||
|
/// A1=0..H8=63) plus the feature weights (mobility N/B/R/Q, passed, isolated, doubled,
|
||||||
|
/// king safety).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record LearnedWeightsSnapshot(int[][] Mg, int[][] Eg, int[] Features);
|
||||||
@@ -7,4 +7,5 @@ public class MoveResultDto
|
|||||||
public bool IsCheck { get; set; }
|
public bool IsCheck { get; set; }
|
||||||
public bool IsCheckmate { get; set; }
|
public bool IsCheckmate { get; set; }
|
||||||
public bool IsStalemate { get; set; }
|
public bool IsStalemate { get; set; }
|
||||||
|
public bool IsThreefoldRepetition { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div id="chessContainer">
|
<div id="chessContainer">
|
||||||
<div id="boardContainer">
|
<section id="boardArea">
|
||||||
|
<div class="playerBar" id="topPlayerBar">
|
||||||
|
<span class="playerDot black"></span>
|
||||||
|
<span class="playerName">Opponent</span>
|
||||||
|
<div class="capturedTray" id="captured-top"></div>
|
||||||
|
<span class="advantage" id="advantage-top"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="chessBoard">
|
<div id="chessBoard">
|
||||||
<!-- Placeholder squares -->
|
<!-- Placeholder squares -->
|
||||||
@for (int i = 0; i < 64; i++)
|
@for (int i = 0; i < 64; i++)
|
||||||
@@ -14,16 +21,41 @@
|
|||||||
<div id="square-@i" class="chessSquare @( (i + i / 8) % 2 == 0 ? "light" : "dark" )"></div>
|
<div id="square-@i" class="chessSquare @( (i + i / 8) % 2 == 0 ? "light" : "dark" )"></div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="textContainer" class="sideContent">
|
<div class="playerBar" id="bottomPlayerBar">
|
||||||
<h1>Chess</h1>
|
<span class="playerDot white"></span>
|
||||||
<p>Click a button to start a game :)</p>
|
<span class="playerName">You</span>
|
||||||
</div>
|
<div class="capturedTray" id="captured-bottom"></div>
|
||||||
|
<span class="advantage" id="advantage-bottom"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div id="buttonContainer" class="sideContent">
|
<aside id="gamePanel">
|
||||||
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
|
<header class="panelHeader">
|
||||||
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button>
|
<span class="panelLogo">♞</span>
|
||||||
|
<h1>Chess</h1>
|
||||||
|
<button id="menuClose" class="iconBtn" aria-label="Close menu" onclick="closeMenu()">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="moveList">
|
||||||
|
<p class="movePlaceholder">Moves will appear here once a game begins.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="statusLine">Start a game to play.</div>
|
||||||
|
|
||||||
|
<div id="panelButtons">
|
||||||
|
<button id="startGameBtn" class="btn btn-primary" onclick="startNewGame()">New Game</button>
|
||||||
|
<button id="startCPUGame" class="btn btn-secondary" onclick="startCPUGame()">Play vs CPU</button>
|
||||||
|
<button id="copyPgnBtn" class="btn btn-ghost" onclick="copyPgn()" style="display: none">Copy PGN</button>
|
||||||
|
<a id="watchLink" href="/watch">Watch other games →</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div id="menuBackdrop" onclick="closeMenu()"></div>
|
||||||
|
|
||||||
|
<div id="mobileBar">
|
||||||
|
<span id="mobileStatus">Start a game to play.</span>
|
||||||
|
<button id="menuToggle" class="btn btn-primary" onclick="toggleMenu()">Menu</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,19 +89,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="colorModal" style="display: none;">
|
||||||
|
<p>Play as:</p>
|
||||||
|
<div id="colorButtonContainer">
|
||||||
|
<button onclick="selectColor('white')">White</button>
|
||||||
|
<button onclick="selectColor('black')">Black</button>
|
||||||
|
<button onclick="selectColor('random')">Random</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script src="~/js/signalr/signalr.min.js"></script>
|
<script src="~/js/signalr/signalr.min.js"></script>
|
||||||
<script src="~/js/ChessScripts/GameState.js"></script>
|
<script src="~/js/ChessScripts/GameState.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/ChessScripts/ChessUtils.js"></script>
|
<script src="~/js/ChessScripts/ChessUtils.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/ChessScripts/ChessAPI.js"></script>
|
<script src="~/js/ChessScripts/ChessAPI.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/ChessScripts/ChessSignalR.js"></script>
|
<script src="~/js/ChessScripts/ChessSignalR.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/ChessScripts/ChessInteractions.js"></script>
|
<script src="~/js/ChessScripts/ChessInteractions.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/ChessScripts/ChessBoard.js"></script>
|
<script src="~/js/ChessScripts/ChessBoard.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/ChessScripts/ChessModals.js"></script>
|
<script src="~/js/ChessScripts/ChessModals.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/ChessScripts/chessMain.js"></script>
|
<script src="~/js/ChessScripts/chessMain.js?v=@ViewData["cssVersion"]"></script>
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Styles {
|
@section Styles {
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
|
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
|
||||||
<link rel="stylesheet" href="~/css/chess/site.css?v=@ViewData["cssVersion"]" />
|
<link rel="stylesheet" href="~/css/chess/site.css?v=@ViewData["cssVersion"]" />
|
||||||
}
|
}
|
||||||
@@ -61,6 +61,7 @@
|
|||||||
<h2 class="section-title">Demos</h2>
|
<h2 class="section-title">Demos</h2>
|
||||||
<div id="buttonWrapper">
|
<div id="buttonWrapper">
|
||||||
<button class="demoButton" onclick="window.location.href='/chess'">Play Chess</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='/particles'">Particle Simulator</button>
|
||||||
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</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 class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
@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">
|
||||||
|
<fieldset class="enginePicker">
|
||||||
|
<legend>White</legend>
|
||||||
|
<select id="whiteEngine">
|
||||||
|
<option value="custom">Custom (v1)</option>
|
||||||
|
<option value="customLearned" selected>Custom (Learned)</option>
|
||||||
|
<option value="stockfish">Stockfish</option>
|
||||||
|
</select>
|
||||||
|
<select id="whiteSkill">
|
||||||
|
@for (int i = 1; i <= 20; i++)
|
||||||
|
{
|
||||||
|
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="enginePicker">
|
||||||
|
<legend>Black</legend>
|
||||||
|
<select id="blackEngine">
|
||||||
|
<option value="custom" selected>Custom (v1)</option>
|
||||||
|
<option value="customLearned">Custom (Learned)</option>
|
||||||
|
<option value="stockfish">Stockfish</option>
|
||||||
|
</select>
|
||||||
|
<select id="blackSkill">
|
||||||
|
@for (int i = 1; i <= 20; i++)
|
||||||
|
{
|
||||||
|
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
|
||||||
|
<fieldset class="enginePicker">
|
||||||
|
<legend>Auto-train games</legend>
|
||||||
|
<input id="autoTrainCount" type="number" min="0" max="16" step="1" />
|
||||||
|
<button id="applyAutoTrain" onclick="Spectate.setAutoTrainCount()">Apply</button>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<a id="backToPlay" href="/chess">← Play a game</a>
|
||||||
|
<a id="viewWeights" href="/weights">View learned weights →</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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
@page
|
||||||
|
@model JoshHeaps.Net.Pages.WeightsModel
|
||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
ViewData["Title"] = "Learned Weights";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div id="weightsHeader">
|
||||||
|
<h1>Learned Piece-Square Weights</h1>
|
||||||
|
<p id="weightsStatus">Where the learned engine thinks each piece belongs.</p>
|
||||||
|
<div id="weightsControls">
|
||||||
|
<div class="heatLegend">
|
||||||
|
<span>low</span>
|
||||||
|
<span class="legendBar"></span>
|
||||||
|
<span>high</span>
|
||||||
|
</div>
|
||||||
|
<button id="refreshWeights" onclick="Weights.refresh()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<a id="backToWatch" href="/watch">← Watch / train</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="weightsSection">
|
||||||
|
<h2>Feature weights</h2>
|
||||||
|
<p class="sectionHint">Learned value of each contextual feature (per normalized unit). Mobility is per piece type; passed pawns are endgame-weighted, king safety midgame-weighted.</p>
|
||||||
|
<div id="featureWeights" class="featurePanel"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="weightsSection">
|
||||||
|
<h2>Midgame tables</h2>
|
||||||
|
<div id="weightsGridMg" class="weightsGrid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="weightsSection">
|
||||||
|
<h2>Endgame tables</h2>
|
||||||
|
<div id="weightsGridEg" class="weightsGrid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script src="~/js/ChessScripts/Weights.js"></script>
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<link rel="stylesheet" href="~/css/chess/weights.css?v=@ViewData["cssVersion"]" />
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Pages
|
||||||
|
{
|
||||||
|
public class WeightsModel : PageModel
|
||||||
|
{
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@{
|
@{
|
||||||
Layout = null;
|
Layout = null;
|
||||||
ViewData["cssVersion"] = "1.0.5"; // <--- change this once to bust cache
|
ViewData["cssVersion"] = "1.0.7"; // <--- change this once to bust cache
|
||||||
}
|
}
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|||||||
@@ -22,9 +22,24 @@ builder.Services.AddSingleton<IBlogService, BlogService>();
|
|||||||
builder.Services.AddSingleton<IChessService, ChessService>();
|
builder.Services.AddSingleton<IChessService, ChessService>();
|
||||||
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
|
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
|
||||||
|
|
||||||
|
builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEngineOptions.SectionName));
|
||||||
|
builder.Services.AddSingleton<ILearnedWeightsStore, LearnedWeightsStore>();
|
||||||
|
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
|
||||||
|
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
|
||||||
|
builder.Services.AddSingleton<IGameStore, GameStore>();
|
||||||
|
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
|
||||||
|
builder.Services.AddSingleton<AutoTrainingSettings>();
|
||||||
|
|
||||||
if (!builder.Environment.IsDevelopment())
|
if (!builder.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
builder.Services.AddHostedService<AutoIpUpdateService>();
|
builder.Services.AddHostedService<AutoIpUpdateService>();
|
||||||
|
|
||||||
|
// Continuously train the learned engine against Stockfish in the background. Toggle off
|
||||||
|
// via ChessEngine:AutoTrain (env ChessEngine__AutoTrain=false) without a redeploy.
|
||||||
|
if (configuration.GetValue($"{ChessEngineOptions.SectionName}:{nameof(ChessEngineOptions.AutoTrain)}", true))
|
||||||
|
builder.Services.AddHostedService<AutoTrainingService>();
|
||||||
|
}
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"profiles": {
|
"profiles": {
|
||||||
"http": {
|
"http": {
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
|
"nativeDebugging": true,
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"applicationUrl": "http://localhost:5200",
|
"applicationUrl": "http://localhost:5200",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"https": {
|
"https": {
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
|
"nativeDebugging": true,
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"applicationUrl": "https://localhost:7118;http://localhost:5200",
|
"applicationUrl": "https://localhost:7118;http://localhost:5200",
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,73 @@
|
|||||||
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Continuously trains the learned engine in the background by keeping a configurable number of
|
||||||
|
/// self-play games running — the learned engine (skill 6) against Stockfish (skill 20), alternating
|
||||||
|
/// which color Stockfish takes so the model trains on both. The target count is read live from
|
||||||
|
/// <see cref="AutoTrainingSettings"/> (adjustable from the website): when a game finishes another
|
||||||
|
/// starts to refill the pool, raising the count starts more, and lowering it lets the surplus drain
|
||||||
|
/// as games finish (0 pauses training). Registered only outside Development and gated by the
|
||||||
|
/// ChessEngine:AutoTrain config flag.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AutoTrainingService(
|
||||||
|
ISelfPlayCoordinator coordinator,
|
||||||
|
AutoTrainingSettings settings,
|
||||||
|
ILogger<AutoTrainingService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private const int LearnedSkill = 6;
|
||||||
|
private const int StockfishSkill = 20;
|
||||||
|
private static readonly TimeSpan _restartBackoff = TimeSpan.FromSeconds(5);
|
||||||
|
private static readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var running = new List<Task>();
|
||||||
|
int started = 0;
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
running.RemoveAll(t => t.IsCompleted);
|
||||||
|
|
||||||
|
int desired = settings.GameCount;
|
||||||
|
bool startFailed = false;
|
||||||
|
|
||||||
|
while (running.Count < desired && !stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Alternate Stockfish's color so the learned engine trains as both white and black.
|
||||||
|
var config = started++ % 2 == 0
|
||||||
|
? new SelfPlayConfig(ChessEngineKind.CustomLearned, LearnedSkill, ChessEngineKind.Stockfish, StockfishSkill)
|
||||||
|
: new SelfPlayConfig(ChessEngineKind.Stockfish, StockfishSkill, ChessEngineKind.CustomLearned, LearnedSkill);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (_, completion) = coordinator.StartGame(config, stoppingToken);
|
||||||
|
running.Add(completion);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Most likely an engine failing to start (e.g. Stockfish). Back off so a
|
||||||
|
// persistent failure doesn't spin a tight loop, then try again.
|
||||||
|
logger.LogError(ex, "Failed to start an auto-training game; retrying after backoff.");
|
||||||
|
startFailed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (startFailed)
|
||||||
|
await Task.Delay(_restartBackoff, stoppingToken);
|
||||||
|
else if (running.Count > 0)
|
||||||
|
// Wake when any game finishes (to refill) or after a short poll (to pick up a
|
||||||
|
// count increase promptly).
|
||||||
|
await Task.WhenAny(Task.WhenAny(running), Task.Delay(_pollInterval, stoppingToken));
|
||||||
|
else
|
||||||
|
// Pool is empty (count is 0) — just poll for the count to change.
|
||||||
|
await Task.Delay(_pollInterval, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runtime-adjustable auto-training settings. Singleton so the value set from the website (via the
|
||||||
|
/// chess controller) is seen live by the background <see cref="AutoTrainingService"/>. Seeded from
|
||||||
|
/// <see cref="ChessEngineOptions.AutoTrainGameCount"/> and clamped to a sane range.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AutoTrainingSettings
|
||||||
|
{
|
||||||
|
/// <summary>Upper bound on concurrent auto-training games (each spawns a Stockfish + a learned engine).</summary>
|
||||||
|
public const int MaxGames = 16;
|
||||||
|
|
||||||
|
private int _gameCount;
|
||||||
|
|
||||||
|
public AutoTrainingSettings(IOptions<ChessEngineOptions> options)
|
||||||
|
=> _gameCount = Clamp(options.Value.AutoTrainGameCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of auto-training games to keep running concurrently. 0 pauses auto-training.
|
||||||
|
/// Reads/writes are atomic; the background service reads this every cycle.
|
||||||
|
/// </summary>
|
||||||
|
public int GameCount
|
||||||
|
{
|
||||||
|
get => Volatile.Read(ref _gameCount);
|
||||||
|
set => Volatile.Write(ref _gameCount, Clamp(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Clamp(int n) => Math.Clamp(n, 0, MaxGames);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using JoshHeaps.Net.Models;
|
using JoshHeaps.Net.Models;
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
@@ -10,6 +10,7 @@ public class BlogService : IBlogService
|
|||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly ILogger<BlogService> _logger;
|
private readonly ILogger<BlogService> _logger;
|
||||||
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
|
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
|
||||||
|
private readonly TimeSpan _staleGrace = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
||||||
|
|
||||||
@@ -65,7 +66,9 @@ public class BlogService : IBlogService
|
|||||||
|
|
||||||
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
|
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)
|
_cache.TryGetValue(key, out var entry);
|
||||||
|
|
||||||
|
if (entry is not null && entry.ExpiresAt > DateTime.UtcNow)
|
||||||
return (T?)entry.Value;
|
return (T?)entry.Value;
|
||||||
|
|
||||||
var result = await factory();
|
var result = await factory();
|
||||||
@@ -73,15 +76,26 @@ public class BlogService : IBlogService
|
|||||||
if (result is not null)
|
if (result is not null)
|
||||||
{
|
{
|
||||||
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
|
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
else if (entry is not null)
|
|
||||||
|
return entry is null ? null : ServeStale<T>(key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale entries are dropped once the grace window closes so that a dead API fails the same
|
||||||
|
// way for every key. Serving them indefinitely let a cached post list render next to 404s on
|
||||||
|
// the posts themselves, which reads as a site bug rather than an outage.
|
||||||
|
private T? ServeStale<T>(string key, CacheEntry entry) where T : class
|
||||||
|
{
|
||||||
|
if (entry.ExpiresAt.Add(_staleGrace) > DateTime.UtcNow)
|
||||||
{
|
{
|
||||||
// API unreachable — serve stale cache
|
_logger.LogWarning("Blog API unreachable, serving stale cache for key {Key}", key);
|
||||||
_logger.LogWarning("Serving stale cache for key {Key}", key);
|
|
||||||
return (T?)entry.Value;
|
return (T?)entry.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
_cache.TryRemove(key, out _);
|
||||||
|
_logger.LogError("Blog API unreachable for over {StaleGrace}, dropping stale cache for key {Key}", _staleGrace, key);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClearCache()
|
public void ClearCache()
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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>The custom engine with the reinforcement-learned piece-square evaluation.</summary>
|
||||||
|
CustomLearned
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
/// Absolute path to the learned-weights file. Leave null to default to
|
||||||
|
/// <c>{ContentRoot}/chess-data/learned-weights.txt</c> (fine for local dev). In
|
||||||
|
/// production set this to a stable, service-writable location OUTSIDE the deploy
|
||||||
|
/// directory (e.g. <c>/var/lib/joshheaps/chess-data/learned-weights.txt</c>) so the
|
||||||
|
/// trained weights survive deploys and avoid deploy-user vs service-user permission
|
||||||
|
/// clashes. Override via the <c>ChessEngine__WeightsPath</c> environment variable.
|
||||||
|
/// </summary>
|
||||||
|
public string? WeightsPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true (and outside Development), a background service continuously plays the learned
|
||||||
|
/// engine against Stockfish to train it. Set to false to stop auto-training without a
|
||||||
|
/// redeploy. Override via the <c>ChessEngine__AutoTrain</c> environment variable.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoTrain { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many auto-training games run concurrently (when <see cref="AutoTrain"/> is on). This is
|
||||||
|
/// the starting value; it can be changed at runtime from the website. Override the default via
|
||||||
|
/// the <c>ChessEngine__AutoTrainGameCount</c> environment variable.
|
||||||
|
/// </summary>
|
||||||
|
public int AutoTrainGameCount { get; set; } = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
|
||||||
|
public sealed class ChessEngineFactory(
|
||||||
|
IOptions<ChessEngineOptions> options,
|
||||||
|
ILearnedWeightsStore weightsStore) : IChessEngineFactory
|
||||||
|
{
|
||||||
|
private readonly ChessEngineKind _kind = options.Value.Engine;
|
||||||
|
|
||||||
|
public IChessEngine Create(int skill) => Create(skill, _kind);
|
||||||
|
|
||||||
|
public IChessEngine Create(int skill, ChessEngineKind kind) => kind switch
|
||||||
|
{
|
||||||
|
ChessEngineKind.Custom => new CustomChessEngine(skill),
|
||||||
|
ChessEngineKind.CustomLearned => new CustomChessEngine(skill, EngineVariant.Learned, weightsStore.WeightsFilePath),
|
||||||
|
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.Pieces.Clear();
|
||||||
gameState.MoveHistory.Clear();
|
gameState.MoveHistory.Clear();
|
||||||
|
gameState.SanHistory.Clear();
|
||||||
|
|
||||||
gameState.WhiteCanCastleKingside = true;
|
gameState.WhiteCanCastleKingside = true;
|
||||||
gameState.WhiteCanCastleQueenside = true;
|
gameState.WhiteCanCastleQueenside = true;
|
||||||
gameState.BlackCanCastleKingside = true;
|
gameState.BlackCanCastleKingside = true;
|
||||||
gameState.BlackCanCastleQueenside = true;
|
gameState.BlackCanCastleQueenside = true;
|
||||||
gameState.EnPassantTarget = null;
|
gameState.EnPassantTarget = null;
|
||||||
|
gameState.HalfmoveClock = 0;
|
||||||
|
|
||||||
SetupBlackPieces(gameState);
|
SetupBlackPieces(gameState);
|
||||||
SetupWhitePieces(gameState);
|
SetupWhitePieces(gameState);
|
||||||
@@ -35,6 +37,9 @@ public class ChessService : IChessService
|
|||||||
gameState.CurrentPlayer = PieceColor.White;
|
gameState.CurrentPlayer = PieceColor.White;
|
||||||
|
|
||||||
UpdateCheckStatus(gameState);
|
UpdateCheckStatus(gameState);
|
||||||
|
|
||||||
|
gameState.PositionHistory.Clear();
|
||||||
|
gameState.PositionHistory.Add(PositionKey(gameState));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SetupBlackPieces(GameState gs)
|
private static void SetupBlackPieces(GameState gs)
|
||||||
@@ -137,12 +142,23 @@ public class ChessService : IChessService
|
|||||||
if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol))
|
if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol))
|
||||||
return new MoveResultDto { Success = false, Message = "Illegal move." };
|
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);
|
PerformMove(gameState, piece, targetPos, moveDto);
|
||||||
|
|
||||||
UpdateCheckStatus(gameState);
|
UpdateCheckStatus(gameState);
|
||||||
|
|
||||||
var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
|
var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
|
||||||
gameState.MoveHistory.Add(notation);
|
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
|
return new MoveResultDto
|
||||||
{
|
{
|
||||||
@@ -150,14 +166,95 @@ public class ChessService : IChessService
|
|||||||
Message = "Move successful.",
|
Message = "Move successful.",
|
||||||
IsCheck = gameState.IsCheck,
|
IsCheck = gameState.IsCheck,
|
||||||
IsCheckmate = gameState.IsCheckmate,
|
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)
|
private static void PerformMove(GameState gs, ChessPiece piece, Position targetPos, MoveDto moveDto)
|
||||||
{
|
{
|
||||||
var oldPos = piece.Position;
|
var oldPos = piece.Position;
|
||||||
var captured = gs.Board[targetPos.Row, targetPos.Col];
|
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);
|
HandleEnPassantIfNeeded(gs, piece, targetPos, ref captured);
|
||||||
|
|
||||||
@@ -179,6 +276,9 @@ public class ChessService : IChessService
|
|||||||
|
|
||||||
UpdateCastlingRights(gs, piece, oldPos);
|
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
|
gs.CurrentPlayer = gs.CurrentPlayer == PieceColor.White
|
||||||
? PieceColor.Black
|
? PieceColor.Black
|
||||||
: PieceColor.White;
|
: PieceColor.White;
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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,
|
||||||
|
ILearnedWeightsStore weightsStore) : IComputerMoveOrchestrator
|
||||||
|
{
|
||||||
|
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state)
|
||||||
|
{
|
||||||
|
var engine = (state.CurrentPlayer == PieceColor.White ? state.WhiteComputer : state.BlackComputer)
|
||||||
|
?? throw new InvalidOperationException($"No engine is set for {state.CurrentPlayer} in game {state.GameId}.");
|
||||||
|
|
||||||
|
var uci = await engine.GetBestMoveAsync(state.ToFen(), state.RepetitionHistory());
|
||||||
|
var move = uci.ToMoveDto(state, CurrentPlayerId(state));
|
||||||
|
|
||||||
|
return await ApplyAndBroadcastAsync(state, move);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PlayRandomMoveAsync(GameState state)
|
||||||
|
{
|
||||||
|
var options = chessService.GetAllLegalMoves(state);
|
||||||
|
|
||||||
|
// No legal moves means the game is already over; let the caller's loop detect it.
|
||||||
|
if (options.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var (piece, moves) = options[Random.Shared.Next(options.Count)];
|
||||||
|
var target = moves[Random.Shared.Next(moves.Count)];
|
||||||
|
|
||||||
|
var move = new MoveDto
|
||||||
|
{
|
||||||
|
GameId = state.GameId,
|
||||||
|
PlayerId = CurrentPlayerId(state),
|
||||||
|
PieceId = piece.Id,
|
||||||
|
SourceRow = piece.Position.Row,
|
||||||
|
SourceCol = piece.Position.Col,
|
||||||
|
TargetRow = target.Row,
|
||||||
|
TargetCol = target.Col,
|
||||||
|
PromotionChoice = null // a pawn cannot reach the last rank within the opening plies
|
||||||
|
};
|
||||||
|
|
||||||
|
await ApplyAndBroadcastAsync(state, move);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(MoveDto move, MoveResultDto result)> ApplyAndBroadcastAsync(GameState state, MoveDto move)
|
||||||
|
{
|
||||||
|
var result = chessService.MakeMove(state, move);
|
||||||
|
|
||||||
|
// Record the played position for training (no-op for non-training games).
|
||||||
|
if (state.Trainer != nint.Zero)
|
||||||
|
weightsStore.Record(state.Trainer, state.ToFen());
|
||||||
|
|
||||||
|
await chessHub.Clients.Group(state.GameId.ToString())
|
||||||
|
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result, state.ToDto());
|
||||||
|
|
||||||
|
return (move, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid CurrentPlayerId(GameState state) =>
|
||||||
|
state.CurrentPlayer == PieceColor.White ? state.WhitePlayerId : state.BlackPlayerId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>Which evaluation the native engine uses.</summary>
|
||||||
|
public enum EngineVariant
|
||||||
|
{
|
||||||
|
/// <summary>The hand-crafted evaluation.</summary>
|
||||||
|
Classic,
|
||||||
|
|
||||||
|
/// <summary>Material plus a learned per-square bonus table loaded from a weights file.</summary>
|
||||||
|
Learned
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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, EngineVariant variant = EngineVariant.Classic, string? weightsPath = null)
|
||||||
|
{
|
||||||
|
Skill = skill;
|
||||||
|
|
||||||
|
// weights= must come last: the native side reads the path as the rest of the
|
||||||
|
// string, which lets it contain ';' and spaces.
|
||||||
|
var options = variant == EngineVariant.Learned
|
||||||
|
? $"skill={skill};variant=learned;weights={weightsPath}"
|
||||||
|
: $"skill={skill}";
|
||||||
|
|
||||||
|
var handle = NativeMethods.engine_create(options);
|
||||||
|
|
||||||
|
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).
|
||||||
|
/// Internal so <see cref="LearnedWeightsStore"/> can share the single import resolver.
|
||||||
|
/// </summary>
|
||||||
|
internal 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);
|
||||||
|
|
||||||
|
// ---- Learned-weights / training ABI (see chess_engine.h) ----
|
||||||
|
|
||||||
|
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||||
|
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||||
|
internal static partial void learned_load(string path);
|
||||||
|
|
||||||
|
[LibraryImport(LibName)]
|
||||||
|
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||||
|
internal static unsafe partial int weights_snapshot(int* outBuf, int outLen);
|
||||||
|
|
||||||
|
[LibraryImport(LibName)]
|
||||||
|
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||||
|
internal static partial IntPtr trainer_create();
|
||||||
|
|
||||||
|
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||||
|
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||||
|
internal static partial void trainer_record(IntPtr trainer, string fen);
|
||||||
|
|
||||||
|
[LibraryImport(LibName)]
|
||||||
|
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||||
|
internal static partial void trainer_apply(IntPtr trainer, int winner, double weight);
|
||||||
|
|
||||||
|
[LibraryImport(LibName)]
|
||||||
|
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||||
|
internal static partial void trainer_destroy(IntPtr trainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory game registry with a delayed-removal lifecycle. Singleton: the game state is
|
||||||
|
/// process-wide, not per-request, so it lives in a service rather than static controller fields.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GameStore(ILearnedWeightsStore weightsStore) : IGameStore
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
||||||
|
private readonly ConcurrentDictionary<Guid, Task> _removalTasks = [];
|
||||||
|
private readonly ConcurrentDictionary<Guid, CancellationTokenSource> _removalCts = [];
|
||||||
|
|
||||||
|
public void Add(GameState game) => _games[game.GameId] = game;
|
||||||
|
|
||||||
|
public bool TryGet(Guid id, out GameState game) => _games.TryGetValue(id, out game!);
|
||||||
|
|
||||||
|
public bool Contains(Guid id) => _games.ContainsKey(id);
|
||||||
|
|
||||||
|
public IReadOnlyCollection<GameState> All => [.. _games.Values];
|
||||||
|
|
||||||
|
public void ScheduleRemove(Guid id, TimeSpan delay)
|
||||||
|
{
|
||||||
|
if (_removalCts.TryRemove(id, out var oldCts))
|
||||||
|
{
|
||||||
|
oldCts.Cancel();
|
||||||
|
oldCts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
_removalCts[id] = cts;
|
||||||
|
|
||||||
|
_removalTasks[id] = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(delay, cts.Token);
|
||||||
|
|
||||||
|
if (_games.TryGetValue(id, out var game))
|
||||||
|
{
|
||||||
|
if (game.WhiteComputer is not null)
|
||||||
|
await game.WhiteComputer.DisposeAsync();
|
||||||
|
if (game.BlackComputer is not null)
|
||||||
|
await game.BlackComputer.DisposeAsync();
|
||||||
|
|
||||||
|
// Free the trainer if the game never reached ApplyLearning (e.g. timed out).
|
||||||
|
if (game.Trainer != nint.Zero)
|
||||||
|
{
|
||||||
|
weightsStore.DestroyTrainer(game.Trainer);
|
||||||
|
game.Trainer = nint.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_games.Remove(id, out _);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (_removalCts.TryGetValue(id, out var currentCts) && currentCts == cts)
|
||||||
|
_removalCts.TryRemove(id, out _);
|
||||||
|
|
||||||
|
cts.Dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Managed facade over the native learned-weights model (see <see cref="ILearnedWeightsStore"/>).
|
||||||
|
/// On construction it points the native engine at the weights file; everything else delegates
|
||||||
|
/// to the shared native ABI in <see cref="CustomChessEngine"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LearnedWeightsStore : ILearnedWeightsStore
|
||||||
|
{
|
||||||
|
private const int Pieces = 6; // Pawn..King
|
||||||
|
private const int Squares = 64;
|
||||||
|
private const int Features = 8; // mobility N/B/R/Q, passed, isolated, doubled, king safety
|
||||||
|
|
||||||
|
public string WeightsFilePath { get; }
|
||||||
|
|
||||||
|
public LearnedWeightsStore(IHostEnvironment env, IOptions<ChessEngineOptions> options, ILogger<LearnedWeightsStore> logger)
|
||||||
|
{
|
||||||
|
// Prefer the configured path (production points this outside the deploy dir); fall
|
||||||
|
// back to the content root for local dev.
|
||||||
|
var configured = options.Value.WeightsPath;
|
||||||
|
WeightsFilePath = string.IsNullOrWhiteSpace(configured)
|
||||||
|
? Path.Combine(env.ContentRootPath, "chess-data", "learned-weights.txt")
|
||||||
|
: configured;
|
||||||
|
|
||||||
|
// A missing/unwritable/misconfigured path must not take down the whole app — the
|
||||||
|
// learned engine just plays from a neutral table and can't persist training.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(WeightsFilePath)!);
|
||||||
|
CustomChessEngine.NativeMethods.learned_load(WeightsFilePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex,
|
||||||
|
"Could not initialize the learned-weights store at {Path}. The learned engine will " +
|
||||||
|
"play from a neutral table and training will not persist. In production set " +
|
||||||
|
"ChessEngine:WeightsPath (env ChessEngine__WeightsPath) to a service-writable directory.",
|
||||||
|
WeightsFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public nint CreateTrainer() => CustomChessEngine.NativeMethods.trainer_create();
|
||||||
|
|
||||||
|
public void Record(nint trainer, string fen) =>
|
||||||
|
CustomChessEngine.NativeMethods.trainer_record(trainer, fen);
|
||||||
|
|
||||||
|
public void ApplyResult(nint trainer, PieceColor winner, double weight) =>
|
||||||
|
CustomChessEngine.NativeMethods.trainer_apply(trainer, winner == PieceColor.White ? 0 : 1, weight);
|
||||||
|
|
||||||
|
public void DestroyTrainer(nint trainer) =>
|
||||||
|
CustomChessEngine.NativeMethods.trainer_destroy(trainer);
|
||||||
|
|
||||||
|
public LearnedWeightsSnapshot Snapshot()
|
||||||
|
{
|
||||||
|
const int total = Pieces * Squares * 2 + Features;
|
||||||
|
var buffer = new int[total];
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (int* p = buffer)
|
||||||
|
CustomChessEngine.NativeMethods.weights_snapshot(p, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
var mg = new int[Pieces][];
|
||||||
|
var eg = new int[Pieces][];
|
||||||
|
|
||||||
|
for (int piece = 0; piece < Pieces; piece++)
|
||||||
|
{
|
||||||
|
mg[piece] = new int[Squares];
|
||||||
|
eg[piece] = new int[Squares];
|
||||||
|
Array.Copy(buffer, piece * Squares, mg[piece], 0, Squares);
|
||||||
|
Array.Copy(buffer, Pieces * Squares + piece * Squares, eg[piece], 0, Squares);
|
||||||
|
}
|
||||||
|
|
||||||
|
var features = new int[Features];
|
||||||
|
Array.Copy(buffer, Pieces * Squares * 2, features, 0, Features);
|
||||||
|
|
||||||
|
return new LearnedWeightsSnapshot(mg, eg, features);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "*";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs CPU-vs-CPU games: builds the game and engines, plays a randomized opening (for training
|
||||||
|
/// variety), drives the move loop to completion, then trains the learned engine from the result.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SelfPlayCoordinator(
|
||||||
|
IChessService chessService,
|
||||||
|
IChessEngineFactory engineFactory,
|
||||||
|
IComputerMoveOrchestrator orchestrator,
|
||||||
|
ILearnedWeightsStore weightsStore,
|
||||||
|
IGameStore gameStore) : ISelfPlayCoordinator
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||||
|
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
|
||||||
|
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
// Abandon a game that goes this long without a move being played — i.e. an engine (usually
|
||||||
|
// Stockfish) that crashed or froze. The game is killed with no result recorded; if it was an
|
||||||
|
// auto-training game the trainer schedules a replacement once this one's task completes.
|
||||||
|
private static readonly TimeSpan _idleTimeout = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
// Plies of random legal moves at the start of a training game, so self-play and
|
||||||
|
// engine-vs-engine games explore different lines instead of replaying one game.
|
||||||
|
private const int _openingRandomPlies = 4;
|
||||||
|
|
||||||
|
public (Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var whiteComputer = engineFactory.Create(config.WhiteSkill, config.WhiteKind);
|
||||||
|
IChessEngine blackComputer;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
blackComputer = engineFactory.Create(config.BlackSkill, config.BlackKind);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Don't leak the first engine if the second fails to start (e.g. Stockfish process).
|
||||||
|
whiteComputer.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var gameState = chessService.CreateNewGame();
|
||||||
|
gameState.IsVsComputer = true;
|
||||||
|
gameState.IsComputerVsComputer = true;
|
||||||
|
gameState.WhiteJoined = true;
|
||||||
|
gameState.BlackJoined = true;
|
||||||
|
gameState.WhitePlayerId = Guid.NewGuid();
|
||||||
|
gameState.BlackPlayerId = Guid.NewGuid();
|
||||||
|
gameState.WhiteEngineKind = config.WhiteKind;
|
||||||
|
gameState.BlackEngineKind = config.BlackKind;
|
||||||
|
gameState.WhiteComputer = whiteComputer;
|
||||||
|
gameState.BlackComputer = blackComputer;
|
||||||
|
|
||||||
|
// When the learned engine is playing, attach a trainer so the outcome can train it.
|
||||||
|
if (config.WhiteKind == ChessEngineKind.CustomLearned || config.BlackKind == ChessEngineKind.CustomLearned)
|
||||||
|
gameState.Trainer = weightsStore.CreateTrainer();
|
||||||
|
|
||||||
|
gameStore.Add(gameState);
|
||||||
|
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||||
|
|
||||||
|
var completion = Task.Run(() => RunAsync(gameState, cancellationToken));
|
||||||
|
return (gameState.GameId, completion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives the game to completion, then trains from it. Never throws — a failure (or a frozen
|
||||||
|
/// engine) just ends the game and the trainer is always freed, so callers can await or ignore.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RunAsync(GameState gameState, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
bool aborted = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Give spectators a moment to join the SignalR group before the first move.
|
||||||
|
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||||
|
|
||||||
|
// Training games open with random moves so they don't replay the same line.
|
||||||
|
if (gameState.Trainer != nint.Zero)
|
||||||
|
for (int i = 0; i < _openingRandomPlies && IsLive(gameState, cancellationToken); i++)
|
||||||
|
{
|
||||||
|
await orchestrator.PlayRandomMoveAsync(gameState);
|
||||||
|
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastMoveCount = gameState.MoveHistory.Count;
|
||||||
|
var lastProgress = DateTime.UtcNow;
|
||||||
|
|
||||||
|
while (IsLive(gameState, cancellationToken))
|
||||||
|
{
|
||||||
|
await PlayMoveWithTimeoutAsync(gameState, cancellationToken);
|
||||||
|
|
||||||
|
// Watchdog: abandon the game if it stops making moves (a crashed or frozen engine
|
||||||
|
// can leave PlayAsync returning without progressing). Reset the clock on a real
|
||||||
|
// move; otherwise bail once nothing has happened for the idle timeout.
|
||||||
|
if (gameState.MoveHistory.Count != lastMoveCount)
|
||||||
|
{
|
||||||
|
lastMoveCount = gameState.MoveHistory.Count;
|
||||||
|
lastProgress = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
else if (DateTime.UtcNow - lastProgress > _idleTimeout)
|
||||||
|
throw new TimeoutException("no move played within the idle timeout");
|
||||||
|
|
||||||
|
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { aborted = true; /* service shutting down */ }
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
aborted = true;
|
||||||
|
Console.WriteLine($"Self-play game {gameState.GameId} abandoned: no move for over " +
|
||||||
|
$"{_idleTimeout.TotalSeconds:n0}s (likely a crashed or frozen engine).");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
aborted = true;
|
||||||
|
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A clean finish trains the learned engine; an abandoned game (cancelled, crashed, or
|
||||||
|
// idle past the timeout) records nothing and just frees its trainer.
|
||||||
|
if (aborted)
|
||||||
|
DiscardTraining(gameState);
|
||||||
|
else
|
||||||
|
ApplyLearning(gameState);
|
||||||
|
|
||||||
|
// A clean finish lingers briefly so spectators see the result; an abandoned game is torn
|
||||||
|
// down immediately so its engines (and any crashed/frozen Stockfish process) are released.
|
||||||
|
if (gameStore.Contains(gameState.GameId))
|
||||||
|
gameStore.ScheduleRemove(gameState.GameId, aborted ? TimeSpan.Zero : _selfPlayResultTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plays one engine move, abandoning it if it exceeds <see cref="_idleTimeout"/> (throwing
|
||||||
|
/// <see cref="TimeoutException"/>) so a single hung move can't block the loop forever. The
|
||||||
|
/// abandoned move's eventual fault — it errors once the game's engines are disposed — is
|
||||||
|
/// observed so it isn't an unobserved task exception.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PlayMoveWithTimeoutAsync(GameState gameState, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var play = orchestrator.PlayAsync(gameState);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await play.WaitAsync(_idleTimeout, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
_ = play.ContinueWith(static t => { _ = t.Exception; }, TaskScheduler.Default);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsLive(GameState gameState, CancellationToken cancellationToken) =>
|
||||||
|
!cancellationToken.IsCancellationRequested
|
||||||
|
&& gameStore.Contains(gameState.GameId)
|
||||||
|
&& !IsGameOver(gameState);
|
||||||
|
|
||||||
|
private static bool IsGameOver(GameState gameState) =>
|
||||||
|
gameState.IsCheckmate || gameState.IsStalemate || gameState.IsThreefoldRepetition || gameState.IsForfeited;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feeds a finished training game's result into the learned weights, then frees the trainer.
|
||||||
|
/// A checkmate is a full-strength result; a material-imbalance draw is a half-strength win
|
||||||
|
/// for the lower-material side (holding a draw while down material is a success; only drawing
|
||||||
|
/// while up is a failure). A balanced draw, forfeit, or unfinished game teaches nothing.
|
||||||
|
/// </summary>
|
||||||
|
private void ApplyLearning(GameState gameState)
|
||||||
|
{
|
||||||
|
if (gameState.Trainer == nint.Zero)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (TryDetermineOutcome(gameState, out var winner, out var weight))
|
||||||
|
weightsStore.ApplyResult(gameState.Trainer, winner, weight);
|
||||||
|
|
||||||
|
weightsStore.DestroyTrainer(gameState.Trainer);
|
||||||
|
gameState.Trainer = nint.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frees an abandoned game's trainer without recording any result — a cancelled, crashed, or
|
||||||
|
/// idle-timed-out game teaches the model nothing.
|
||||||
|
/// </summary>
|
||||||
|
private void DiscardTraining(GameState gameState)
|
||||||
|
{
|
||||||
|
if (gameState.Trainer == nint.Zero)
|
||||||
|
return;
|
||||||
|
|
||||||
|
weightsStore.DestroyTrainer(gameState.Trainer);
|
||||||
|
gameState.Trainer = nint.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines the trainable outcome of a finished game: the winning color and the reward
|
||||||
|
/// weight. Returns false when the game teaches nothing (balanced draw, forfeit, unfinished).
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryDetermineOutcome(GameState gameState, out PieceColor winner, out double weight)
|
||||||
|
{
|
||||||
|
winner = PieceColor.White;
|
||||||
|
weight = 1.0;
|
||||||
|
|
||||||
|
if (gameState.IsCheckmate)
|
||||||
|
{
|
||||||
|
// The side to move is the mated one, so the winner is the other color.
|
||||||
|
winner = gameState.CurrentPlayer == PieceColor.White ? PieceColor.Black : PieceColor.White;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameState.IsStalemate || gameState.IsThreefoldRepetition)
|
||||||
|
{
|
||||||
|
var (white, black) = MaterialCounts(gameState);
|
||||||
|
|
||||||
|
if (white == black)
|
||||||
|
return false; // a balanced draw carries no signal
|
||||||
|
|
||||||
|
winner = white < black ? PieceColor.White : PieceColor.Black;
|
||||||
|
weight = 0.5;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // forfeit / unfinished
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Total non-king material per side (P=1, N=B=3, R=5, Q=9), for draw adjudication.</summary>
|
||||||
|
private static (int white, int black) MaterialCounts(GameState gameState)
|
||||||
|
{
|
||||||
|
int white = 0, black = 0;
|
||||||
|
|
||||||
|
for (int row = 0; row < 8; row++)
|
||||||
|
for (int col = 0; col < 8; col++)
|
||||||
|
{
|
||||||
|
var piece = gameState.Board[row, col];
|
||||||
|
|
||||||
|
if (piece is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
int value = piece.Type switch
|
||||||
|
{
|
||||||
|
PieceType.Pawn => 1,
|
||||||
|
PieceType.Knight => 3,
|
||||||
|
PieceType.Bishop => 3,
|
||||||
|
PieceType.Rook => 5,
|
||||||
|
PieceType.Queen => 9,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
|
||||||
|
if (piece.Color == PieceColor.White)
|
||||||
|
white += value;
|
||||||
|
else
|
||||||
|
black += value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (white, black);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,19 @@
|
|||||||
using JoshHeaps.Net.Hubs;
|
|
||||||
using JoshHeaps.Net.Models;
|
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Reflection;
|
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Services.Implementations;
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
public sealed class Stockfish : IAsyncDisposable
|
public sealed class Stockfish : IChessEngine
|
||||||
{
|
{
|
||||||
private readonly Process _p;
|
private readonly Process _p;
|
||||||
private readonly StreamWriter _stdin;
|
private readonly StreamWriter _stdin;
|
||||||
private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>();
|
private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>();
|
||||||
private readonly int _skill;
|
private readonly int _skill;
|
||||||
|
|
||||||
|
public int Skill => _skill;
|
||||||
|
|
||||||
public Stockfish(int skill = 20, int hash = 256)
|
public Stockfish(int skill = 20, int hash = 256)
|
||||||
{
|
{
|
||||||
_skill = skill;
|
_skill = skill;
|
||||||
@@ -77,8 +74,9 @@ public sealed class Stockfish : IAsyncDisposable
|
|||||||
WaitFor("readyok").GetAwaiter().GetResult();
|
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($"position fen {fen}");
|
||||||
Send($"go depth {_skill}");
|
Send($"go depth {_skill}");
|
||||||
string? best = null;
|
string? best = null;
|
||||||
@@ -109,145 +107,4 @@ public sealed class Stockfish : IAsyncDisposable
|
|||||||
await _p.WaitForExitAsync();
|
await _p.WaitForExitAsync();
|
||||||
_p.Dispose();
|
_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,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,27 @@
|
|||||||
|
using JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
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 using the configured default engine.
|
||||||
|
/// 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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new engine instance for a single game using an explicitly chosen engine
|
||||||
|
/// (e.g. for picking a different engine per side in a CPU-vs-CPU game). The caller owns
|
||||||
|
/// and disposes it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="skill">The desired playing strength / search depth.</param>
|
||||||
|
/// <param name="kind">The engine implementation to create.</param>
|
||||||
|
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
|
||||||
|
IChessEngine Create(int skill, ChessEngineKind kind);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives a computer move: asks the side-to-move's 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 side-to-move's engine pick a move for the current position, applies it, and
|
||||||
|
/// broadcasts it. The engine is taken from the game's per-side computer assignments.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="state">The game to play a move in.</param>
|
||||||
|
/// <returns>The applied move and its result.</returns>
|
||||||
|
Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plays a uniformly-random legal move for the side to move (no engine), applying and
|
||||||
|
/// broadcasting it. Used to randomize the opening of training games so self-play and
|
||||||
|
/// engine-vs-engine games don't replay the same line every time.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="state">The game to play a random move in.</param>
|
||||||
|
Task PlayRandomMoveAsync(GameState state);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Process-wide registry of in-memory games and their cleanup lifecycle. Shared by the HTTP
|
||||||
|
/// controller (human and single-computer games) and the self-play coordinator (CPU-vs-CPU and
|
||||||
|
/// auto-training games), so every game is reachable from one place for lookup and spectating.
|
||||||
|
/// </summary>
|
||||||
|
public interface IGameStore
|
||||||
|
{
|
||||||
|
/// <summary>Add (or replace) a game in the registry.</summary>
|
||||||
|
void Add(GameState game);
|
||||||
|
|
||||||
|
/// <summary>Look a game up by id.</summary>
|
||||||
|
bool TryGet(Guid id, out GameState game);
|
||||||
|
|
||||||
|
/// <summary>Whether a game with this id is still in the registry.</summary>
|
||||||
|
bool Contains(Guid id);
|
||||||
|
|
||||||
|
/// <summary>Snapshot of all games currently in the registry.</summary>
|
||||||
|
IReadOnlyCollection<GameState> All { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schedule removal of a game after <paramref name="delay"/>, cancelling any prior schedule
|
||||||
|
/// for it. On removal the game's engines are disposed and any training accumulator freed.
|
||||||
|
/// </summary>
|
||||||
|
void ScheduleRemove(Guid id, TimeSpan delay);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thin managed facade over the native learned-weights model. The weights, all feature
|
||||||
|
/// computation, per-game accumulation, the update rule, and persistence live in the native
|
||||||
|
/// engine; this just points it at the weights file, hands out per-game trainers, and reads
|
||||||
|
/// the table back for visualization.
|
||||||
|
/// </summary>
|
||||||
|
public interface ILearnedWeightsStore
|
||||||
|
{
|
||||||
|
/// <summary>Absolute path to the weights file the native engine loads and saves.</summary>
|
||||||
|
string WeightsFilePath { get; }
|
||||||
|
|
||||||
|
/// <summary>A copy of the current weights (midgame/endgame tables + feature weights).</summary>
|
||||||
|
LearnedWeightsSnapshot Snapshot();
|
||||||
|
|
||||||
|
/// <summary>Creates a per-game training accumulator. The caller owns it (see <see cref="DestroyTrainer"/>).</summary>
|
||||||
|
nint CreateTrainer();
|
||||||
|
|
||||||
|
/// <summary>Records one played position (post-move FEN) into a trainer.</summary>
|
||||||
|
void Record(nint trainer, string fen);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a finished game's outcome to the global weights (and saves): rewards the
|
||||||
|
/// winner's squares/features, punishes the loser's, scaled by <paramref name="weight"/>.
|
||||||
|
/// </summary>
|
||||||
|
void ApplyResult(nint trainer, PieceColor winner, double weight);
|
||||||
|
|
||||||
|
/// <summary>Frees a trainer. Safe to call with <see cref="nint.Zero"/>.</summary>
|
||||||
|
void DestroyTrainer(nint trainer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>Per-side engine and strength for a CPU-vs-CPU game.</summary>
|
||||||
|
public sealed record SelfPlayConfig(
|
||||||
|
ChessEngineKind WhiteKind, int WhiteSkill,
|
||||||
|
ChessEngineKind BlackKind, int BlackSkill);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates and runs CPU-vs-CPU games to completion: randomized opening, move loop, and (when
|
||||||
|
/// the learned engine plays) feeding the result back into the learned weights. Used by the
|
||||||
|
/// spectator "watch" endpoint and by the auto-trainer.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISelfPlayCoordinator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Create, register, and start running a self-play game. Returns immediately with the new
|
||||||
|
/// game's id and a task that completes when the game finishes (or is cancelled). Callers
|
||||||
|
/// that only need the id can ignore the task; the auto-trainer awaits it to start the next.
|
||||||
|
/// </summary>
|
||||||
|
(Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -5,5 +5,8 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ChessEngine": {
|
||||||
|
"Engine": "Custom"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,8 @@
|
|||||||
"BlogApi": {
|
"BlogApi": {
|
||||||
"BaseUrl": "https://media.joshheaps.net",
|
"BaseUrl": "https://media.joshheaps.net",
|
||||||
"InvalidateKey": "CHANGE_ME"
|
"InvalidateKey": "CHANGE_ME"
|
||||||
|
},
|
||||||
|
"ChessEngine": {
|
||||||
|
"Engine": "Custom"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
#boardContainer {
|
#chessBoard {
|
||||||
position: relative;
|
|
||||||
width: fit-content;
|
|
||||||
margin: 2vw auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#chessBoard {
|
|
||||||
width: 60vw;
|
|
||||||
height: 60vw;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(8, 1fr);
|
grid-template-columns: repeat(8, 1fr);
|
||||||
grid-template-rows: repeat(8, 1fr);
|
grid-template-rows: repeat(8, 1fr);
|
||||||
@@ -165,6 +157,47 @@
|
|||||||
pointer-events: none; /* ensures img doesn't steal the click */
|
pointer-events: none; /* ensures img doesn't steal the click */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#colorModal {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 20px rgba(0,0,0,0.6);
|
||||||
|
color: white;
|
||||||
|
z-index: 1000;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#colorModal p {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#colorButtonContainer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#colorButtonContainer button {
|
||||||
|
background-color: #2c2c2c;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#colorButtonContainer button:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
.chessSquare .coordinate-label {
|
.chessSquare .coordinate-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-size: 1.2vw;
|
font-size: 1.2vw;
|
||||||
|
|||||||
@@ -1,137 +1,429 @@
|
|||||||
html, body {
|
:root {
|
||||||
background-color: #2b2c30;
|
--bg: #2b2c30;
|
||||||
color: #d6d6d6;
|
--panel: #21232a;
|
||||||
cursor: default;
|
--panel-row: rgba(255, 255, 255, 0.035);
|
||||||
|
--bar: #1e2026;
|
||||||
|
--accent: #8cd5ed;
|
||||||
|
--accent-ink: #10222a;
|
||||||
|
--text: #d6d6d6;
|
||||||
|
--muted: #8b8f99;
|
||||||
|
--line: #34373f;
|
||||||
|
--bar-h: clamp(34px, 5.5vh, 50px);
|
||||||
|
--radius: 12px;
|
||||||
|
--font: 'Outfit', system-ui, -apple-system, Segoe UI, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
cursor: default;
|
||||||
|
/* The chess page is a fixed, single-screen app: never scroll. */
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#startGameBtn {
|
main {
|
||||||
background-color: #8cd5ed;
|
height: 100%;
|
||||||
color: #262626;
|
|
||||||
border-radius: 30px;
|
|
||||||
border: 0px;
|
|
||||||
cursor: pointer;
|
|
||||||
order: 1;
|
|
||||||
padding: 2vh;
|
|
||||||
margin: 2vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#startCPUGame {
|
/* ---- Layout ------------------------------------------------------------ */
|
||||||
background-color: #8cd5ed;
|
|
||||||
color: #262626;
|
|
||||||
border-radius: 30px;
|
|
||||||
border: 0px;
|
|
||||||
cursor: pointer;
|
|
||||||
order: 1;
|
|
||||||
padding: 2vh;
|
|
||||||
margin: 2vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* Portrait / narrow: board on top, panel stacked below. */
|
||||||
#chessContainer {
|
#chessContainer {
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-evenly;
|
gap: clamp(8px, 1.6vh, 16px);
|
||||||
height: 100vh;
|
padding: clamp(10px, 2vh, 20px);
|
||||||
box-sizing: border-box;
|
overflow: hidden;
|
||||||
padding: 5vw;
|
}
|
||||||
|
|
||||||
|
#boardArea {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: min(94vw, 56vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
#chessBoard {
|
#chessBoard {
|
||||||
aspect-ratio: 1 / 1;
|
width: 100%;
|
||||||
width: 90vw; /* use the smaller of width or height */
|
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 90vw;
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
#boardContainer {
|
#gamePanel {
|
||||||
align-content: center;
|
width: min(94vw, 56vh);
|
||||||
flex-grow: 1;
|
flex: 1 1 auto;
|
||||||
}
|
min-height: 0;
|
||||||
|
|
||||||
.sideContent {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-content: center;
|
background: var(--panel);
|
||||||
justify-content: center;
|
border-radius: var(--radius);
|
||||||
text-align: center;
|
overflow: hidden;
|
||||||
|
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
#textContainer {
|
/* Landscape / wide: board left, game panel right (chess.com style). */
|
||||||
flex-direction: column;
|
|
||||||
order: -1;
|
|
||||||
flex-shrink: 1;
|
|
||||||
font-size: large;
|
|
||||||
margin: 5vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
#buttonContainer {
|
|
||||||
flex-grow: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Put UI to left/right when screen is short */
|
|
||||||
@media (min-aspect-ratio: 1/1) {
|
@media (min-aspect-ratio: 1/1) {
|
||||||
#chessContainer {
|
#chessContainer {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto, auto;
|
|
||||||
grid-template-rows: auto, auto;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
gap: clamp(16px, 3vw, 48px);
|
||||||
height: 100vh;
|
padding: clamp(12px, 3vh, 28px);
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sideContent {
|
#boardArea {
|
||||||
display: flex;
|
width: auto;
|
||||||
flex-direction: column;
|
height: 100%;
|
||||||
flex-grow: 1;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
padding: min(5vw, 5vh);
|
|
||||||
}
|
|
||||||
|
|
||||||
#boardContainer {
|
|
||||||
grid-row: 1 / span 2;
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#textContainer {
|
|
||||||
grid-row: 1;
|
|
||||||
grid-column: 2;
|
|
||||||
text-align: center;
|
|
||||||
font-size: x-large;
|
|
||||||
}
|
|
||||||
|
|
||||||
#buttonContainer {
|
|
||||||
grid-row: 2;
|
|
||||||
grid-column: 2;
|
|
||||||
text-align: left;
|
|
||||||
align-self: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
#startGameBtn {
|
|
||||||
padding: 1vw;
|
|
||||||
font-size: large;
|
|
||||||
}
|
|
||||||
|
|
||||||
#startCPUGame {
|
|
||||||
padding: 1vw;
|
|
||||||
margin: 1vw;
|
|
||||||
font-size: large;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#chessBoard {
|
#chessBoard {
|
||||||
aspect-ratio: 1 / 1;
|
width: min(72vh, 54vw);
|
||||||
flex-shrink: 1;
|
height: min(72vh, 54vw);
|
||||||
width: min(90vh, 90vw);
|
|
||||||
max-height: 90vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#difficultyButtonContainer {
|
.playerBar {
|
||||||
grid-template-columns: repeat(10, 1fr);
|
width: min(72vh, 54vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
#gamePanel {
|
||||||
|
width: clamp(300px, 26vw, 380px);
|
||||||
|
height: min(86vh, 100%);
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Player bars ------------------------------------------------------- */
|
||||||
|
|
||||||
|
.playerBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: var(--bar-h);
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--bar);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerDot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerDot.white {
|
||||||
|
background: #ededed;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerDot.black {
|
||||||
|
background: #2c2c2c;
|
||||||
|
box-shadow: 0 0 0 1px #565656 inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerName {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capturedTray {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capturedPiece {
|
||||||
|
height: clamp(15px, calc(var(--bar-h) * 0.58), 26px);
|
||||||
|
width: auto;
|
||||||
|
margin-right: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advantage {
|
||||||
|
margin-left: auto;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Game panel -------------------------------------------------------- */
|
||||||
|
|
||||||
|
.panelHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelLogo {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#moveList {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movePlaceholder {
|
||||||
|
margin: 0;
|
||||||
|
padding: 18px 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2.4em 1fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 14px;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveRow:nth-child(odd) {
|
||||||
|
background: var(--panel-row);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveNum {
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveSan {
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveSan.latest {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusLine {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusLine.alert {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panelButtons {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: clamp(0.92rem, 1vw, 1.05rem);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: clamp(10px, 1.4vh, 14px) 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.08s ease, filter 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #363a44;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #424752;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: #2a2e37;
|
||||||
|
}
|
||||||
|
|
||||||
|
#watchLink {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#watchLink:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Mobile chrome (hidden on desktop) --------------------------------- */
|
||||||
|
|
||||||
|
.iconBtn {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#menuClose {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default (tablet/desktop): no mobile bar, menu button, or sheet chrome. */
|
||||||
|
#mobileBar,
|
||||||
|
#menuBackdrop,
|
||||||
|
#menuClose {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Phone layout ------------------------------------------------------ */
|
||||||
|
/* The full panel doesn't fit alongside a usable board on a phone, so we show
|
||||||
|
the board + a slim status/menu bar, and tuck the move list and secondary
|
||||||
|
actions into a slide-up sheet. The page itself still never scrolls. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
#chessContainer {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#boardArea {
|
||||||
|
width: min(94vw, 60vh);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerBar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right rail becomes a hidden bottom sheet, revealed by the Menu button. */
|
||||||
|
#gamePanel {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
top: auto;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 85dvh;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
z-index: 60;
|
||||||
|
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.menu-open #gamePanel {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The slim bar already shows status, so hide the sheet's inline copy. */
|
||||||
|
#gamePanel #statusLine {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menuClose {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menuBackdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
z-index: 55;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.menu-open #menuBackdrop {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobileBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: min(94vw, 60vh);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobileStatus {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobileStatus.alert {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobileBar #menuToggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 10px 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
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;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enginePicker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.3rem 0.6rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enginePicker legend {
|
||||||
|
color: #9a9a9a;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#watchControls select {
|
||||||
|
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,187 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: #141414;
|
||||||
|
color: #d6d6d6;
|
||||||
|
font-family: "Segoe UI", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weightsHeader {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem 1rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weightsHeader h1 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weightsStatus {
|
||||||
|
color: #9a9a9a;
|
||||||
|
margin: 0.25rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weightsControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatLegend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9a9a9a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendBar {
|
||||||
|
width: 120px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: linear-gradient(to right, rgba(232, 74, 74, 1), #2a2a2a, rgba(74, 134, 232, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#refreshWeights {
|
||||||
|
background-color: #8cd5ed;
|
||||||
|
color: #262626;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#refreshWeights:hover {
|
||||||
|
background-color: #a5e0f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#backToWatch {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: #8cd5ed;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weightsSection {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 1200px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weightsSection h2 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin: 1rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHint {
|
||||||
|
text-align: center;
|
||||||
|
color: #9a9a9a;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weightsGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featurePanel {
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureLabel {
|
||||||
|
flex: 0 0 6.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #c8c8c8;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureTrack {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 14px;
|
||||||
|
background-color: #232323;
|
||||||
|
border-radius: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureBar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 7px;
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureValue {
|
||||||
|
flex: 0 0 3rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #e8e8e8;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weightBoard {
|
||||||
|
background-color: #1c1c1c;
|
||||||
|
border: 1px solid #2e2e2e;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weightBoardHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weightBoardIcon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weightRange {
|
||||||
|
margin-left: auto;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniHeat {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1rem repeat(8, 34px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatSquare {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: #f0f0f0;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2px 0;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ const ChessAPI = {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
async createCPUGame(difficulty) {
|
async createCPUGame(difficulty, color = "random") {
|
||||||
const response = await fetch(`/api/chess/new/${difficulty}`);
|
const response = await fetch(`/api/chess/new/${difficulty}?color=${color}`);
|
||||||
return await response.json();
|
return await response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -14,6 +14,20 @@ const ChessAPI = {
|
|||||||
return await response.json();
|
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) {
|
async getLegalMoves(pieceId) {
|
||||||
const response = await fetch(`/api/chess/${GameState.currentGameId}/legalMoves/${pieceId}`);
|
const response = await fetch(`/api/chess/${GameState.currentGameId}/legalMoves/${pieceId}`);
|
||||||
if (!response.ok) throw new Error("API failed");
|
if (!response.ok) throw new Error("API failed");
|
||||||
@@ -32,13 +46,15 @@ const ChessAPI = {
|
|||||||
throw new Error(message || "Invalid move or not your turn.");
|
throw new Error(message || "Invalid move or not your turn.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
// The move endpoint returns both the move result and the full resulting
|
||||||
|
// board state, so the caller can render without a follow-up fetch.
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
if (!result.success) {
|
if (!data.result.success) {
|
||||||
throw new Error(result.message || "Invalid move or not your turn.");
|
throw new Error(data.result.message || "Invalid move or not your turn.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleMove(targetRow, targetCol) {
|
async handleMove(targetRow, targetCol) {
|
||||||
@@ -71,16 +87,14 @@ const ChessAPI = {
|
|||||||
GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]);
|
GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const moveResult = await this.makeMove(moveDto);
|
const { result, state } = await this.makeMove(moveDto);
|
||||||
|
|
||||||
const updatedGame = await this.getGameState(GameState.currentGameId);
|
ChessBoard.renderState(state);
|
||||||
ChessBoard.renderPieces(updatedGame.pieces);
|
|
||||||
|
|
||||||
GameState.clearSelection();
|
GameState.clearSelection();
|
||||||
ChessInteractions.clearHighlights();
|
ChessInteractions.clearHighlights();
|
||||||
|
|
||||||
await ChessSignalR.notifyMoveMade(moveDto, moveResult);
|
this.alertGameStatusChange(result);
|
||||||
this.alertGameStatusChange(moveResult);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("❌ " + error.message);
|
alert("❌ " + error.message);
|
||||||
@@ -101,9 +115,13 @@ const ChessAPI = {
|
|||||||
} else if (moveResult.isStalemate) {
|
} else if (moveResult.isStalemate) {
|
||||||
alert("🤝 Stalemate!");
|
alert("🤝 Stalemate!");
|
||||||
gameOver = true;
|
gameOver = true;
|
||||||
|
} else if (moveResult.isThreefoldRepetition) {
|
||||||
|
alert("🤝 Draw by threefold repetition!");
|
||||||
|
gameOver = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gameOver) {
|
if (gameOver) {
|
||||||
|
showCopyPgn(GameState.currentGameId);
|
||||||
await ChessSignalR.leaveGame();
|
await ChessSignalR.leaveGame();
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|||||||
@@ -1,4 +1,91 @@
|
|||||||
|
// Material value per piece type, indexed by PieceType (0=Pawn .. 5=King).
|
||||||
|
const PIECE_VALUES = [1, 5, 3, 3, 9, 0];
|
||||||
|
|
||||||
const ChessBoard = {
|
const ChessBoard = {
|
||||||
|
// Render from a full game-state payload (the shape returned by the move/state
|
||||||
|
// endpoints and pushed over SignalR). Ignores state older than what's already
|
||||||
|
// shown, so a slow initial fetch can't clobber a move that arrived first.
|
||||||
|
renderState(state) {
|
||||||
|
if (!state || !GameState.shouldApply(state.version)) return;
|
||||||
|
this.renderPieces(state.pieces);
|
||||||
|
this.renderCapturedTrays(state.pieces, state.capturedPieces);
|
||||||
|
this.renderMoveList(state.sanHistory);
|
||||||
|
this.renderStatus(state);
|
||||||
|
GameState.setVersion(state.version);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Two-column numbered move list (white move, black move), latest highlighted.
|
||||||
|
renderMoveList(sanHistory) {
|
||||||
|
const list = document.getElementById("moveList");
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
const san = sanHistory ?? [];
|
||||||
|
|
||||||
|
if (san.length === 0) {
|
||||||
|
list.innerHTML = '<p class="movePlaceholder">Moves will appear here once a game begins.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < san.length; i += 2) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "moveRow";
|
||||||
|
|
||||||
|
const num = document.createElement("span");
|
||||||
|
num.className = "moveNum";
|
||||||
|
num.textContent = `${i / 2 + 1}.`;
|
||||||
|
row.appendChild(num);
|
||||||
|
|
||||||
|
row.appendChild(this.moveCell(san[i], i === san.length - 1));
|
||||||
|
|
||||||
|
if (i + 1 < san.length)
|
||||||
|
row.appendChild(this.moveCell(san[i + 1], i + 1 === san.length - 1));
|
||||||
|
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.scrollTop = list.scrollHeight;
|
||||||
|
},
|
||||||
|
|
||||||
|
moveCell(san, isLatest) {
|
||||||
|
const cell = document.createElement("span");
|
||||||
|
cell.className = isLatest ? "moveSan latest" : "moveSan";
|
||||||
|
cell.textContent = san;
|
||||||
|
return cell;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderStatus(state) {
|
||||||
|
let text;
|
||||||
|
let alert = false;
|
||||||
|
|
||||||
|
if (state.isCheckmate) {
|
||||||
|
const winner = state.currentPlayer === "White" ? "Black" : "White";
|
||||||
|
text = `Checkmate — ${winner} wins`;
|
||||||
|
alert = true;
|
||||||
|
} else if (state.isStalemate) {
|
||||||
|
text = "Draw — stalemate";
|
||||||
|
alert = true;
|
||||||
|
} else if (state.isThreefoldRepetition) {
|
||||||
|
text = "Draw — threefold repetition";
|
||||||
|
alert = true;
|
||||||
|
} else {
|
||||||
|
text = `${state.currentPlayer} to move`;
|
||||||
|
if (state.isCheck) {
|
||||||
|
text += " — check";
|
||||||
|
alert = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror to both the desktop panel status and the mobile bar status.
|
||||||
|
["statusLine", "mobileStatus"].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text;
|
||||||
|
el.classList.toggle("alert", alert);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
renderPieces(pieces) {
|
renderPieces(pieces) {
|
||||||
this.clearAllSquares();
|
this.clearAllSquares();
|
||||||
this.renderCoordinateLabels();
|
this.renderCoordinateLabels();
|
||||||
@@ -7,6 +94,48 @@ const ChessBoard = {
|
|||||||
this.highlightPreviousMove();
|
this.highlightPreviousMove();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Show each side's captured pieces and the leading side's material advantage,
|
||||||
|
// arranged so the current player's tray sits below the board.
|
||||||
|
renderCapturedTrays(activePieces, capturedPieces) {
|
||||||
|
const captured = capturedPieces ?? [];
|
||||||
|
|
||||||
|
// A captured piece's color is the side that lost it, so White's haul is the
|
||||||
|
// captured Black pieces, and vice versa.
|
||||||
|
const whiteCaptured = captured.filter(p => p.color === 1);
|
||||||
|
const blackCaptured = captured.filter(p => p.color === 0);
|
||||||
|
|
||||||
|
// Net material from pieces still on the board, so promotions count correctly.
|
||||||
|
const advantage = (activePieces ?? []).reduce(
|
||||||
|
(sum, p) => sum + (p.color === 0 ? PIECE_VALUES[p.type] : -PIECE_VALUES[p.type]), 0);
|
||||||
|
|
||||||
|
const white = { captured: whiteCaptured, advantage: Math.max(advantage, 0) };
|
||||||
|
const black = { captured: blackCaptured, advantage: Math.max(-advantage, 0) };
|
||||||
|
|
||||||
|
const isWhite = GameState.currentPlayerIsWhite !== false;
|
||||||
|
this.fillCapturedTray("bottom", isWhite ? white : black);
|
||||||
|
this.fillCapturedTray("top", isWhite ? black : white);
|
||||||
|
},
|
||||||
|
|
||||||
|
fillCapturedTray(position, side) {
|
||||||
|
const tray = document.getElementById(`captured-${position}`);
|
||||||
|
const badge = document.getElementById(`advantage-${position}`);
|
||||||
|
if (!tray || !badge) return;
|
||||||
|
|
||||||
|
tray.innerHTML = "";
|
||||||
|
[...side.captured]
|
||||||
|
.sort((a, b) => PIECE_VALUES[a.type] - PIECE_VALUES[b.type])
|
||||||
|
.forEach(piece => {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = ChessUtils.getPieceImageUrl(piece);
|
||||||
|
img.alt = piece.type;
|
||||||
|
img.className = "capturedPiece";
|
||||||
|
img.draggable = false;
|
||||||
|
tray.appendChild(img);
|
||||||
|
});
|
||||||
|
|
||||||
|
badge.textContent = side.advantage > 0 ? `+${side.advantage}` : "";
|
||||||
|
},
|
||||||
|
|
||||||
clearAllSquares() {
|
clearAllSquares() {
|
||||||
for (let i = 0; i < 64; i++) {
|
for (let i = 0; i < 64; i++) {
|
||||||
const square = document.getElementById(`square-${i}`);
|
const square = document.getElementById(`square-${i}`);
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ const ChessModals = {
|
|||||||
resolve(difficulty);
|
resolve(difficulty);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
promptColor() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
document.getElementById("colorModal").style.display = "block";
|
||||||
|
window.selectColor = (color) => {
|
||||||
|
document.getElementById("colorModal").style.display = "none";
|
||||||
|
resolve(color);
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ const ChessSignalR = {
|
|||||||
console.error("❌ SignalR connection closed:", err?.message);
|
console.error("❌ SignalR connection closed:", err?.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto) => {
|
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto, state) => {
|
||||||
await this.handleMoveUpdate(gameId, moveDto, moveResultDto);
|
await this.handleMoveUpdate(gameId, moveDto, moveResultDto, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => {
|
||||||
|
await this.handleGameOver(gameId, winner, reason);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -24,20 +28,28 @@ const ChessSignalR = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleMoveUpdate(gameId, moveDto, moveResultDto) {
|
async handleMoveUpdate(gameId, moveDto, moveResultDto, state) {
|
||||||
if (gameId !== GameState.currentGameId) return;
|
if (gameId !== GameState.currentGameId) return;
|
||||||
|
|
||||||
const gameState = await ChessAPI.getGameState(gameId);
|
// Drop the echo of our own move and any out-of-order delivery.
|
||||||
|
if (!GameState.shouldApply(state?.version)) return;
|
||||||
|
|
||||||
GameState.setPreviousMove([moveDto.sourceRow, moveDto.sourceCol], [moveDto.targetRow, moveDto.targetCol]);
|
GameState.setPreviousMove([moveDto.sourceRow, moveDto.sourceCol], [moveDto.targetRow, moveDto.targetCol]);
|
||||||
ChessBoard.renderPieces(gameState.pieces);
|
ChessBoard.renderState(state);
|
||||||
|
|
||||||
ChessAPI.alertGameStatusChange(moveResultDto);
|
ChessAPI.alertGameStatusChange(moveResultDto);
|
||||||
},
|
},
|
||||||
|
|
||||||
async notifyMoveMade(moveDto, moveResult) {
|
async handleGameOver(gameId, winner, reason) {
|
||||||
if (this.connection) {
|
if (gameId !== GameState.currentGameId) return;
|
||||||
await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult);
|
|
||||||
}
|
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 leaveGame() {
|
async leaveGame() {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ const GameState = {
|
|||||||
legalMoves: [],
|
legalMoves: [],
|
||||||
previousMoveStart: null,
|
previousMoveStart: null,
|
||||||
previousMoveEnd: null,
|
previousMoveEnd: null,
|
||||||
|
// Highest ply (move count) already rendered. Lets us ignore stale or
|
||||||
|
// already-applied updates that arrive out of order, including the echo
|
||||||
|
// of our own move.
|
||||||
|
lastVersion: -1,
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.currentGameId = null;
|
this.currentGameId = null;
|
||||||
@@ -15,12 +19,22 @@ const GameState = {
|
|||||||
this.legalMoves = [];
|
this.legalMoves = [];
|
||||||
this.previousMoveStart = null;
|
this.previousMoveStart = null;
|
||||||
this.previousMoveEnd = null;
|
this.previousMoveEnd = null;
|
||||||
|
this.lastVersion = -1;
|
||||||
|
},
|
||||||
|
|
||||||
|
shouldApply(version) {
|
||||||
|
return typeof version !== "number" || version > this.lastVersion;
|
||||||
|
},
|
||||||
|
|
||||||
|
setVersion(version) {
|
||||||
|
if (typeof version === "number") this.lastVersion = version;
|
||||||
},
|
},
|
||||||
|
|
||||||
setGameInfo(gameId, playerId, isWhite) {
|
setGameInfo(gameId, playerId, isWhite) {
|
||||||
this.currentGameId = gameId;
|
this.currentGameId = gameId;
|
||||||
this.currentPlayerId = playerId;
|
this.currentPlayerId = playerId;
|
||||||
this.currentPlayerIsWhite = isWhite;
|
this.currentPlayerIsWhite = isWhite;
|
||||||
|
this.lastVersion = -1;
|
||||||
},
|
},
|
||||||
|
|
||||||
setSelectedPiece(piece) {
|
setSelectedPiece(piece) {
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
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, _moveResult, state) =>
|
||||||
|
this.handleMoveUpdate(gameId, moveDto, state));
|
||||||
|
|
||||||
|
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();
|
||||||
|
await this.loadAutoTrainCount();
|
||||||
|
setInterval(() => this.refreshGames(), 5000);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auto-training runs server-side; show its target count and let it be changed here.
|
||||||
|
async loadAutoTrainCount() {
|
||||||
|
const input = document.getElementById("autoTrainCount");
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/chess/autotrain");
|
||||||
|
const data = await response.json();
|
||||||
|
input.max = data.max;
|
||||||
|
// Don't clobber the value while the user is editing it.
|
||||||
|
if (document.activeElement !== input)
|
||||||
|
input.value = data.count;
|
||||||
|
} catch {
|
||||||
|
// Leave the control as-is if auto-training status can't be read.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setAutoTrainCount() {
|
||||||
|
const input = document.getElementById("autoTrainCount");
|
||||||
|
const count = Math.max(0, parseInt(input.value, 10) || 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/chess/autotrain?count=${count}`, { method: "POST" });
|
||||||
|
const data = await response.json();
|
||||||
|
input.value = data.count;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Could not set the auto-training game count.", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async startCpuGame() {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
whiteEngine: document.getElementById("whiteEngine").value,
|
||||||
|
whiteSkill: document.getElementById("whiteSkill").value,
|
||||||
|
blackEngine: document.getElementById("blackEngine").value,
|
||||||
|
blackSkill: document.getElementById("blackSkill").value
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/chess/watch/cpu?${params}`);
|
||||||
|
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,
|
||||||
|
whiteEngine: game.whiteEngine,
|
||||||
|
blackEngine: game.blackEngine
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
this.renderFromState(gameId, await response.json());
|
||||||
|
},
|
||||||
|
|
||||||
|
renderFromState(gameId, state) {
|
||||||
|
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, state) {
|
||||||
|
if (!this.games.has(gameId)) return;
|
||||||
|
|
||||||
|
// Render from the pushed state; fall back to a fetch only if it's missing.
|
||||||
|
if (state) this.renderFromState(gameId, state);
|
||||||
|
else 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 `${this.engineName(stored.whiteEngine)} (W) vs ${this.engineName(stored.blackEngine)} (B)`;
|
||||||
|
if (stored.isVsComputer) return "Vs CPU";
|
||||||
|
return "Player vs Player";
|
||||||
|
},
|
||||||
|
|
||||||
|
engineName(kind) {
|
||||||
|
switch (kind) {
|
||||||
|
case "CustomLearned": return "Learned";
|
||||||
|
case "Custom": return "Custom";
|
||||||
|
case "Stockfish": return "Stockfish";
|
||||||
|
default: return kind || "CPU";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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");
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
const Weights = {
|
||||||
|
async init() {
|
||||||
|
await this.refresh();
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
let data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/chess/weights");
|
||||||
|
data = await response.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Could not load weights.", err);
|
||||||
|
document.getElementById("weightsStatus").textContent = "Could not load weights.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderBoardSet(data.mg, "weightsGridMg");
|
||||||
|
this.renderBoardSet(data.eg, "weightsGridEg");
|
||||||
|
this.renderFeatures(data.features);
|
||||||
|
|
||||||
|
const trained = [...data.mg, ...data.eg].some(b => b.squares.some(v => v !== 0))
|
||||||
|
|| data.features.some(f => f.value !== 0);
|
||||||
|
|
||||||
|
document.getElementById("weightsStatus").textContent = trained
|
||||||
|
? "Where the learned engine thinks each piece belongs. Blue = preferred, red = avoided. Midgame vs endgame tables are blended by how much material is left."
|
||||||
|
: "No training yet — everything is neutral. Run some learned games on the Watch page.";
|
||||||
|
},
|
||||||
|
|
||||||
|
// pieces: [{ name, squares[64] }]. Prepends an "Overall" board summing the set.
|
||||||
|
renderBoardSet(pieces, containerId) {
|
||||||
|
const overall = new Array(64).fill(0);
|
||||||
|
for (const piece of pieces)
|
||||||
|
for (let sq = 0; sq < 64; sq++)
|
||||||
|
overall[sq] += piece.squares[sq];
|
||||||
|
|
||||||
|
const boards = [{ name: "Overall", squares: overall }, ...pieces];
|
||||||
|
const grid = document.getElementById(containerId);
|
||||||
|
grid.innerHTML = "";
|
||||||
|
boards.forEach(board => grid.appendChild(this.buildBoard(board)));
|
||||||
|
},
|
||||||
|
|
||||||
|
buildBoard(board) {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "weightBoard";
|
||||||
|
|
||||||
|
const maxAbs = board.squares.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
|
||||||
|
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "weightBoardHeader";
|
||||||
|
if (board.name !== "Overall") {
|
||||||
|
const icon = document.createElement("img");
|
||||||
|
icon.src = `/images/Chess Images/White${board.name}.svg`;
|
||||||
|
icon.alt = board.name;
|
||||||
|
icon.className = "weightBoardIcon";
|
||||||
|
header.appendChild(icon);
|
||||||
|
}
|
||||||
|
const title = document.createElement("span");
|
||||||
|
title.textContent = board.name;
|
||||||
|
header.appendChild(title);
|
||||||
|
const range = document.createElement("span");
|
||||||
|
range.className = "weightRange";
|
||||||
|
range.textContent = maxAbs === 0 ? "neutral" : `±${maxAbs}`;
|
||||||
|
header.appendChild(range);
|
||||||
|
wrapper.appendChild(header);
|
||||||
|
|
||||||
|
const heat = document.createElement("div");
|
||||||
|
heat.className = "miniHeat";
|
||||||
|
|
||||||
|
for (let row = 0; row < 8; row++) {
|
||||||
|
const rankLabel = document.createElement("div");
|
||||||
|
rankLabel.className = "heatLabel";
|
||||||
|
rankLabel.textContent = 8 - row; // rank 8 at top, 1 at bottom
|
||||||
|
heat.appendChild(rankLabel);
|
||||||
|
|
||||||
|
for (let col = 0; col < 8; col++) {
|
||||||
|
const rank = 7 - row; // rank index, 0 = rank 1
|
||||||
|
const sq = rank * 8 + col; // white-relative square (A1 = 0)
|
||||||
|
heat.appendChild(this.buildSquare(board.squares[sq], sq, maxAbs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
heat.appendChild(this.cornerSpacer());
|
||||||
|
for (let col = 0; col < 8; col++) {
|
||||||
|
const fileLabel = document.createElement("div");
|
||||||
|
fileLabel.className = "heatLabel";
|
||||||
|
fileLabel.textContent = String.fromCharCode(97 + col);
|
||||||
|
heat.appendChild(fileLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.appendChild(heat);
|
||||||
|
return wrapper;
|
||||||
|
},
|
||||||
|
|
||||||
|
buildSquare(value, sq, maxAbs) {
|
||||||
|
const cell = document.createElement("div");
|
||||||
|
cell.className = "heatSquare";
|
||||||
|
|
||||||
|
if (value !== 0 && maxAbs > 0) {
|
||||||
|
const ratio = Math.abs(value) / maxAbs;
|
||||||
|
const alpha = (0.12 + 0.88 * ratio).toFixed(3);
|
||||||
|
cell.style.backgroundColor = value > 0
|
||||||
|
? `rgba(74, 134, 232, ${alpha})` // high -> blue
|
||||||
|
: `rgba(232, 74, 74, ${alpha})`; // low -> red
|
||||||
|
cell.textContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = String.fromCharCode(97 + (sq & 7));
|
||||||
|
const rank = (sq >> 3) + 1;
|
||||||
|
cell.title = `${file}${rank}: ${value}`;
|
||||||
|
return cell;
|
||||||
|
},
|
||||||
|
|
||||||
|
cornerSpacer() {
|
||||||
|
const spacer = document.createElement("div");
|
||||||
|
spacer.className = "heatLabel";
|
||||||
|
return spacer;
|
||||||
|
},
|
||||||
|
|
||||||
|
// features: [{ name, value }]
|
||||||
|
renderFeatures(features) {
|
||||||
|
const panel = document.getElementById("featureWeights");
|
||||||
|
panel.innerHTML = "";
|
||||||
|
|
||||||
|
const maxAbs = features.reduce((m, f) => Math.max(m, Math.abs(f.value)), 0);
|
||||||
|
|
||||||
|
features.forEach(feature => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "featureRow";
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "featureLabel";
|
||||||
|
label.textContent = feature.name;
|
||||||
|
|
||||||
|
const track = document.createElement("div");
|
||||||
|
track.className = "featureTrack";
|
||||||
|
const bar = document.createElement("div");
|
||||||
|
bar.className = "featureBar";
|
||||||
|
const ratio = maxAbs === 0 ? 0 : Math.abs(feature.value) / maxAbs;
|
||||||
|
bar.style.width = `${(ratio * 100).toFixed(1)}%`;
|
||||||
|
bar.style.backgroundColor = feature.value >= 0
|
||||||
|
? "rgba(74, 134, 232, 0.85)"
|
||||||
|
: "rgba(232, 74, 74, 0.85)";
|
||||||
|
track.appendChild(bar);
|
||||||
|
|
||||||
|
const value = document.createElement("span");
|
||||||
|
value.className = "featureValue";
|
||||||
|
value.textContent = feature.value;
|
||||||
|
|
||||||
|
row.append(label, track, value);
|
||||||
|
panel.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("load", () => Weights.init());
|
||||||
|
|
||||||
|
console.log("Weights.js loaded");
|
||||||
@@ -1,5 +1,65 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// Mobile slide-up menu (move list + secondary actions).
|
||||||
|
function toggleMenu() {
|
||||||
|
document.body.classList.toggle("menu-open");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
document.body.classList.remove("menu-open");
|
||||||
|
}
|
||||||
|
|
||||||
|
window.toggleMenu = toggleMenu;
|
||||||
|
window.closeMenu = closeMenu;
|
||||||
|
|
||||||
|
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() {
|
async function startNewGame() {
|
||||||
|
closeMenu();
|
||||||
await ChessSignalR.stopConnection();
|
await ChessSignalR.stopConnection();
|
||||||
|
await forfeitCurrentGame();
|
||||||
|
resetCopyPgn();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const gameData = await ChessAPI.joinGame();
|
const gameData = await ChessAPI.joinGame();
|
||||||
@@ -16,7 +76,7 @@ async function startNewGame() {
|
|||||||
await ChessSignalR.setupConnection();
|
await ChessSignalR.setupConnection();
|
||||||
|
|
||||||
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||||
ChessBoard.renderPieces(gameState.pieces);
|
ChessBoard.renderState(gameState);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start new game:", error);
|
console.error("Failed to start new game:", error);
|
||||||
@@ -24,11 +84,15 @@ async function startNewGame() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startCPUGame() {
|
async function startCPUGame() {
|
||||||
|
closeMenu();
|
||||||
await ChessSignalR.stopConnection();
|
await ChessSignalR.stopConnection();
|
||||||
|
await forfeitCurrentGame();
|
||||||
|
resetCopyPgn();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const difficulty = await ChessModals.promptDifficulty();
|
const difficulty = await ChessModals.promptDifficulty();
|
||||||
const gameData = await ChessAPI.createCPUGame(difficulty);
|
const color = await ChessModals.promptColor();
|
||||||
|
const gameData = await ChessAPI.createCPUGame(difficulty, color);
|
||||||
|
|
||||||
GameState.setGameInfo(gameData.gameId, gameData.id, gameData.isWhite);
|
GameState.setGameInfo(gameData.gameId, gameData.id, gameData.isWhite);
|
||||||
GameState.clearPreviousMove();
|
GameState.clearPreviousMove();
|
||||||
@@ -42,7 +106,7 @@ async function startCPUGame() {
|
|||||||
await ChessSignalR.setupConnection();
|
await ChessSignalR.setupConnection();
|
||||||
|
|
||||||
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||||
ChessBoard.renderPieces(gameState.pieces);
|
ChessBoard.renderState(gameState);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start CPU game:", error);
|
console.error("Failed to start CPU game:", error);
|
||||||
@@ -62,7 +126,7 @@ async function resumeSavedGame() {
|
|||||||
GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true");
|
GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true");
|
||||||
|
|
||||||
await ChessSignalR.setupConnection();
|
await ChessSignalR.setupConnection();
|
||||||
ChessBoard.renderPieces(gameState.pieces);
|
ChessBoard.renderState(gameState);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Saved game not found or expired.", err);
|
console.warn("Saved game not found or expired.", err);
|
||||||
|
|||||||
@@ -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,114 @@
|
|||||||
|
/* 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;
|
||||||
|
|
||||||
|
/* Opaque per-game training accumulator (see the learned-weights ABI at the bottom). */
|
||||||
|
typedef struct Trainer* TrainerHandle;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* ---- Learned-weights / training ABI ----
|
||||||
|
* The learned engine's weights live process-globally here. The host orchestrates games but
|
||||||
|
* owns no chess logic: it points the engine at the weights file, records each played
|
||||||
|
* position, and applies the game's result. */
|
||||||
|
|
||||||
|
/* Load the global learned weights from `path` and remember it for later saves. Idempotent;
|
||||||
|
* a missing/short file leaves the weights neutral. Call once before learned play/training. */
|
||||||
|
CHESS_API void CHESS_CALL learned_load(const char* path);
|
||||||
|
|
||||||
|
/* Copy the global weights out for visualization: 6*64 midgame + 6*64 endgame (PAWN..KING,
|
||||||
|
* squares 0..63) + feature weights. Returns the count written, or CHESS_ERR_BUFFER if
|
||||||
|
* out_len is too small (needs >= 776). */
|
||||||
|
CHESS_API int CHESS_CALL weights_snapshot(int* out, int out_len);
|
||||||
|
|
||||||
|
/* Create / destroy a per-game training accumulator. Safe to destroy NULL. */
|
||||||
|
CHESS_API TrainerHandle CHESS_CALL trainer_create(void);
|
||||||
|
CHESS_API void CHESS_CALL trainer_destroy(TrainerHandle trainer);
|
||||||
|
|
||||||
|
/* Record one played position (post-move FEN) into the accumulator. */
|
||||||
|
CHESS_API void CHESS_CALL trainer_record(TrainerHandle trainer, const char* fen);
|
||||||
|
|
||||||
|
/* Apply a finished game's outcome to the global weights and save: rewards the winner's
|
||||||
|
* occupied squares / features and punishes the loser's, scaled by `weight` (e.g. 0.5 for a
|
||||||
|
* material-imbalance draw). winner: 0 = white, 1 = black. */
|
||||||
|
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle trainer, int winner, double weight);
|
||||||
|
|
||||||
|
#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,887 @@
|
|||||||
|
/* 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 <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <fstream>
|
||||||
|
#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)); }
|
||||||
|
|
||||||
|
/* Eval variant for an engine handle. CLASSIC = the hand-crafted evaluate(); LEARNED =
|
||||||
|
* material + learned phase-split piece-square tables + learned feature weights. */
|
||||||
|
enum EvalVariant : int { EVAL_CLASSIC = 0, EVAL_LEARNED = 1 };
|
||||||
|
|
||||||
|
/* The learned feature knobs (beyond the piece-square tables). Each has one weight learned
|
||||||
|
* from game outcomes; its activation is computed by compute_features(). Mobility is per
|
||||||
|
* piece type. Order is fixed — it is the on-disk and snapshot layout after the two tables. */
|
||||||
|
enum Feature : int {
|
||||||
|
FEAT_MOB_N, FEAT_MOB_B, FEAT_MOB_R, FEAT_MOB_Q, /* legal-move counts, per piece type */
|
||||||
|
FEAT_PASSED, /* passed pawns, endgame-weighted */
|
||||||
|
FEAT_ISOLATED, /* isolated pawns */
|
||||||
|
FEAT_DOUBLED, /* doubled pawns */
|
||||||
|
FEAT_KING, /* king pawn-shelter, midgame-weighted */
|
||||||
|
FEATURE_NB
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Per-handle eval configuration, snapshotted from the global learned weights at
|
||||||
|
* engine_create so the search reads a stable copy. The tables are white-relative: a black
|
||||||
|
* piece indexes the rank-mirrored square (sq ^ 56). `mg`/`eg` are blended by game phase.
|
||||||
|
* Indexed by chess::PieceType (PAWN..KING). Only consulted when variant == EVAL_LEARNED. */
|
||||||
|
struct EvalParams {
|
||||||
|
int variant = EVAL_CLASSIC;
|
||||||
|
int mg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
int eg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
int featW[FEATURE_NB] = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Internal engine state. One ChessEngine = one game. The transposition table is NOT
|
||||||
|
* here: it is the shared g_tt above. */
|
||||||
|
struct ChessEngine {
|
||||||
|
int skill = 20; /* 1..20 from the UI; controls search depth */
|
||||||
|
EvalParams eval; /* which evaluation the search uses, plus any learned weights */
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The process-global learned weights: the single source of truth, loaded from disk once and
|
||||||
|
* updated in place by training. Engine handles snapshot it at creation; the visualization
|
||||||
|
* snapshots it on demand. Guarded by g_weightsMutex for updates/saves (eval reads its own
|
||||||
|
* per-handle copy, so it never touches this concurrently). */
|
||||||
|
struct LearnedWeights {
|
||||||
|
int mg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
int eg[chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
int featW[FEATURE_NB] = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
static LearnedWeights g_weights;
|
||||||
|
static std::mutex g_weightsMutex;
|
||||||
|
static std::string g_weightsPath;
|
||||||
|
|
||||||
|
/* Per-game training accumulator (one per learned CPU-vs-CPU game). Records, per ply, where
|
||||||
|
* each side's pieces sat (split into midgame/endgame by phase) and each side's feature
|
||||||
|
* activations; trainer_apply turns the totals into weight nudges. Squares are white-relative
|
||||||
|
* (black indexes sq ^ 56), so a side's tally lines up with the shared white-relative table. */
|
||||||
|
struct Trainer {
|
||||||
|
double mgOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
double egOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {};
|
||||||
|
double featAcc[chess::COLOR_NB][FEATURE_NB] = {};
|
||||||
|
int plies = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "variant=learned" in the options selects the learned eval; anything else is classic. */
|
||||||
|
static int parse_variant(const char* options) {
|
||||||
|
if (!options) return EVAL_CLASSIC;
|
||||||
|
const char* p = std::strstr(options, "variant=");
|
||||||
|
if (!p) return EVAL_CLASSIC;
|
||||||
|
return std::strncmp(p + 8, "learned", 7) == 0 ? EVAL_LEARNED : EVAL_CLASSIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On-disk format: 6*64 mg ints (PAWN..KING, squares 0..63), then 6*64 eg ints, then
|
||||||
|
* FEATURE_NB feature ints, whitespace-separated. A missing file or short read leaves the
|
||||||
|
* rest neutral (0), so an absent weights file just means "train from a blank slate".
|
||||||
|
* Caller holds g_weightsMutex. */
|
||||||
|
static void load_global_weights(const char* path) {
|
||||||
|
g_weights = LearnedWeights{}; /* reset to neutral before loading */
|
||||||
|
|
||||||
|
if (!path || !*path) return;
|
||||||
|
std::ifstream f(path);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq)
|
||||||
|
if (!(f >> g_weights.mg[pt][sq])) return;
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq)
|
||||||
|
if (!(f >> g_weights.eg[pt][sq])) return;
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i)
|
||||||
|
if (!(f >> g_weights.featW[i])) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Persist g_weights to g_weightsPath in the format load_global_weights reads. Caller holds the lock. */
|
||||||
|
static void save_global_weights() {
|
||||||
|
if (g_weightsPath.empty()) return;
|
||||||
|
std::ofstream f(g_weightsPath);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq) f << g_weights.mg[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq) f << g_weights.eg[pt][sq] << (sq == 63 ? '\n' : ' ');
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featW[i] << (i == FEATURE_NB - 1 ? '\n' : ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int castleIncentive(const chess::Position& pos, chess::Color c) {
|
||||||
|
chess::Bitboard pcs = pos.pieces();
|
||||||
|
int total = 0;
|
||||||
|
while (pcs) {
|
||||||
|
chess::Square s = chess::pop_lsb(pcs);
|
||||||
|
chess::Piece pc = pos.piece_on(s);
|
||||||
|
chess::Color c = chess::color_of(pc);
|
||||||
|
total += piece_value(chess::type_of(pc));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ? (total / 10) : 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Learned (phase-split tables + feature knobs) evaluation ---------------------------
|
||||||
|
* The model is a linear combination of features whose weights are learned from outcomes:
|
||||||
|
* eval = Σ pieces [ material + blend(mg, eg, phase) ] + Σ features featW[i]·activation[i]
|
||||||
|
* compute_features() is the single source of feature activations, used by BOTH the eval here
|
||||||
|
* and the trainer, so the two can never disagree. Constants below are the only tunables. */
|
||||||
|
|
||||||
|
/* Per-game-outcome learning rates and clamps. Squares accumulate occupancy (plies on a
|
||||||
|
* square, summed); features accumulate normalized per-ply activation (averaged, divided by a
|
||||||
|
* nominal scale so high-magnitude mobility doesn't dwarf the small pawn-structure terms). */
|
||||||
|
static constexpr double SQUARE_LR = 0.5;
|
||||||
|
static constexpr int SQ_CLAMP = 250;
|
||||||
|
static constexpr double FEAT_LR = 2.0;
|
||||||
|
static constexpr int FEAT_CLAMP = 500;
|
||||||
|
static constexpr double FEAT_SCALE[FEATURE_NB] = { 4, 6, 8, 14, 2, 1, 1, 2 };
|
||||||
|
|
||||||
|
/* Game phase in [0,1] from remaining non-pawn material (PeSTO weights N=B=1, R=2, Q=4; max
|
||||||
|
* 24 for both full sides): 0 = opening, 1 = bare kings. Drives the mg/eg table blend and
|
||||||
|
* the phase weighting of the passed-pawn (×phase) and king-safety (×(1−phase)) features. */
|
||||||
|
static double game_phase(const chess::Position& pos) {
|
||||||
|
int npm = chess::popcount(pos.pieces(chess::KNIGHT)) * 1
|
||||||
|
+ chess::popcount(pos.pieces(chess::BISHOP)) * 1
|
||||||
|
+ chess::popcount(pos.pieces(chess::ROOK)) * 2
|
||||||
|
+ chess::popcount(pos.pieces(chess::QUEEN)) * 4;
|
||||||
|
constexpr int MAX = 24;
|
||||||
|
if (npm >= MAX) return 0.0;
|
||||||
|
return double(MAX - npm) / MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blend a midgame and endgame value by phase, rounding per-piece (so training credits a
|
||||||
|
* square the same way the eval reads it). */
|
||||||
|
static int blend(int mg, int eg, double phase) {
|
||||||
|
return int(std::lround((1.0 - phase) * mg + phase * eg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fills `out[FEATURE_NB]` with one color's raw feature activations for a position. The piece-
|
||||||
|
* square tables handle "where pieces belong"; these capture context a static table can't:
|
||||||
|
* legal mobility (per piece type, so pins reduce it), passed pawns (endgame-weighted), pawn
|
||||||
|
* structure, and king shelter (midgame-weighted). Ported nowhere — this is the only copy. */
|
||||||
|
static void compute_features(chess::Position& pos, chess::Color c, double phase, double out[FEATURE_NB]) {
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i) out[i] = 0.0;
|
||||||
|
|
||||||
|
/* Mobility: legal moves for color c, bucketed by the moving piece's type. */
|
||||||
|
chess::MoveList moves;
|
||||||
|
pos.generate_legal_for(c, moves);
|
||||||
|
for (int i = 0; i < moves.size(); ++i) {
|
||||||
|
switch (chess::type_of(pos.piece_on(moves.moves[i].from()))) {
|
||||||
|
case chess::KNIGHT: out[FEAT_MOB_N] += 1; break;
|
||||||
|
case chess::BISHOP: out[FEAT_MOB_B] += 1; break;
|
||||||
|
case chess::ROOK: out[FEAT_MOB_R] += 1; break;
|
||||||
|
case chess::QUEEN: out[FEAT_MOB_Q] += 1; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pawn structure. */
|
||||||
|
chess::Bitboard pawns = pos.pieces(c, chess::PAWN);
|
||||||
|
chess::Bitboard bb = pawns;
|
||||||
|
while (bb) {
|
||||||
|
chess::Square s = chess::pop_lsb(bb);
|
||||||
|
|
||||||
|
if (!(front_span(c, s) & pos.pieces(~c, chess::PAWN))) { /* passed */
|
||||||
|
chess::Rank r = chess::rank_of(s);
|
||||||
|
int toPromotion = (c == chess::WHITE) ? (chess::RANK_8 - r) : (r - chess::RANK_1);
|
||||||
|
out[FEAT_PASSED] += (6 - toPromotion) * phase; /* 0..5 ranks advanced, late-game */
|
||||||
|
}
|
||||||
|
if (front_span_file_only(c, s) & pawns) /* doubled (friendly pawn ahead) */
|
||||||
|
out[FEAT_DOUBLED] += 1;
|
||||||
|
|
||||||
|
chess::File f = chess::file_of(s);
|
||||||
|
chess::Bitboard adjacent = 0;
|
||||||
|
if (f > chess::FILE_A) adjacent |= chess::file_bb(chess::File(f - 1));
|
||||||
|
if (f < chess::FILE_H) adjacent |= chess::file_bb(chess::File(f + 1));
|
||||||
|
if (!(adjacent & pawns)) /* isolated */
|
||||||
|
out[FEAT_ISOLATED] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* King safety: friendly pawns sheltering the king (its file + adjacent files, the two
|
||||||
|
* ranks in front), worth more in the midgame. */
|
||||||
|
chess::Square k = pos.king_square(c);
|
||||||
|
chess::File kf = chess::file_of(k);
|
||||||
|
chess::Rank kr = chess::rank_of(k);
|
||||||
|
chess::Bitboard kingFiles = chess::file_bb(kf);
|
||||||
|
if (kf > chess::FILE_A) kingFiles |= chess::file_bb(chess::File(kf - 1));
|
||||||
|
if (kf < chess::FILE_H) kingFiles |= chess::file_bb(chess::File(kf + 1));
|
||||||
|
chess::Bitboard shelterRanks = 0;
|
||||||
|
for (int d = 1; d <= 2; ++d) {
|
||||||
|
int rr = (c == chess::WHITE) ? (kr + d) : (kr - d);
|
||||||
|
if (rr >= 0 && rr <= 7) shelterRanks |= (0xFFULL << (8 * rr));
|
||||||
|
}
|
||||||
|
out[FEAT_KING] += chess::popcount(kingFiles & shelterRanks & pawns) * (1.0 - phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Learned eval (white-positive/absolute, like evaluate()): material + phase-blended piece-
|
||||||
|
* square tables + learned feature weights. Black pieces index the rank-mirrored square
|
||||||
|
* (s ^ 56) so both colors share one white-relative table. Non-const because mobility
|
||||||
|
* generates legal moves (which the position's move generator does via do/undo). */
|
||||||
|
static int evaluateLearned(chess::Position& pos, const EvalParams& ep) {
|
||||||
|
double phase = game_phase(pos);
|
||||||
|
int score = 0;
|
||||||
|
|
||||||
|
chess::Bitboard white = pos.pieces(chess::WHITE);
|
||||||
|
while (white) {
|
||||||
|
chess::Square s = chess::pop_lsb(white);
|
||||||
|
chess::PieceType pt = chess::type_of(pos.piece_on(s));
|
||||||
|
score += piece_value(pt) + blend(ep.mg[pt][s], ep.eg[pt][s], phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
chess::Bitboard black = pos.pieces(chess::BLACK);
|
||||||
|
while (black) {
|
||||||
|
chess::Square s = chess::pop_lsb(black);
|
||||||
|
chess::PieceType pt = chess::type_of(pos.piece_on(s));
|
||||||
|
score -= piece_value(pt) + blend(ep.mg[pt][s ^ 56], ep.eg[pt][s ^ 56], phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
double wFeat[FEATURE_NB], bFeat[FEATURE_NB];
|
||||||
|
compute_features(pos, chess::WHITE, phase, wFeat);
|
||||||
|
compute_features(pos, chess::BLACK, phase, bFeat);
|
||||||
|
|
||||||
|
double feature = 0.0;
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i)
|
||||||
|
feature += ep.featW[i] * (wFeat[i] - bFeat[i]) / FEAT_SCALE[i];
|
||||||
|
score += int(std::lround(feature));
|
||||||
|
|
||||||
|
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(chess::Position& pos, bool whiteToMove, const EvalParams& ep) {
|
||||||
|
int s = (ep.variant == EVAL_LEARNED) ? evaluateLearned(pos, ep) : 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; }
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
e->eval.variant = parse_variant(options);
|
||||||
|
if (e->eval.variant == EVAL_LEARNED) {
|
||||||
|
/* Snapshot the current global weights so the search reads a stable copy (training
|
||||||
|
* updates the global between games; the weights path is owned by learned_load). */
|
||||||
|
std::lock_guard<std::mutex> lock(g_weightsMutex);
|
||||||
|
std::memcpy(e->eval.mg, g_weights.mg, sizeof e->eval.mg);
|
||||||
|
std::memcpy(e->eval.eg, g_weights.eg, sizeof e->eval.eg);
|
||||||
|
std::memcpy(e->eval.featW, g_weights.featW, sizeof e->eval.featW);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
const EvalParams* eval = nullptr; /* eval config for this search; set by engine_best_move */
|
||||||
|
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, *ctx.eval);
|
||||||
|
|
||||||
|
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;
|
||||||
|
ctx.eval = &engine->eval;
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Learned-weights / training C ABI --------------------------------------------------
|
||||||
|
* The managed side orchestrates games but owns no chess logic: it tells the engine where
|
||||||
|
* to load/save the global weights, records each played position, and applies the result. */
|
||||||
|
|
||||||
|
CHESS_API void CHESS_CALL learned_load(const char* path) {
|
||||||
|
std::lock_guard<std::mutex> lock(g_weightsMutex);
|
||||||
|
g_weightsPath = path ? path : "";
|
||||||
|
load_global_weights(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
CHESS_API int CHESS_CALL weights_snapshot(int* out, int out_len) {
|
||||||
|
const int need = 6 * 64 * 2 + FEATURE_NB; /* mg + eg (PAWN..KING) + features = 776 */
|
||||||
|
if (!out || out_len < need) return CHESS_ERR_BUFFER;
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(g_weightsMutex);
|
||||||
|
int n = 0;
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq) out[n++] = g_weights.mg[pt][sq];
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq) out[n++] = g_weights.eg[pt][sq];
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i) out[n++] = g_weights.featW[i];
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
CHESS_API TrainerHandle CHESS_CALL trainer_create(void) {
|
||||||
|
return new (std::nothrow) Trainer();
|
||||||
|
}
|
||||||
|
|
||||||
|
CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) {
|
||||||
|
if (!t || !fen || !*fen) return;
|
||||||
|
ensure_initialized();
|
||||||
|
|
||||||
|
chess::Position pos = chess::Position::from_fen(fen);
|
||||||
|
double phase = game_phase(pos);
|
||||||
|
|
||||||
|
/* Per-square occupancy, split into midgame/endgame by phase, white-relative. */
|
||||||
|
chess::Bitboard occ = pos.pieces();
|
||||||
|
while (occ) {
|
||||||
|
chess::Square s = chess::pop_lsb(occ);
|
||||||
|
chess::Piece pc = pos.piece_on(s);
|
||||||
|
chess::Color c = chess::color_of(pc);
|
||||||
|
chess::PieceType pt = chess::type_of(pc);
|
||||||
|
int relSq = (c == chess::WHITE) ? int(s) : (int(s) ^ 56);
|
||||||
|
t->mgOcc[c][pt][relSq] += (1.0 - phase);
|
||||||
|
t->egOcc[c][pt][relSq] += phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-side feature activations. */
|
||||||
|
double w[FEATURE_NB], b[FEATURE_NB];
|
||||||
|
compute_features(pos, chess::WHITE, phase, w);
|
||||||
|
compute_features(pos, chess::BLACK, phase, b);
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i) {
|
||||||
|
t->featAcc[chess::WHITE][i] += w[i];
|
||||||
|
t->featAcc[chess::BLACK][i] += b[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
t->plies++;
|
||||||
|
}
|
||||||
|
|
||||||
|
CHESS_API void CHESS_CALL trainer_apply(TrainerHandle t, int winner, double weight) {
|
||||||
|
if (!t) return;
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(g_weightsMutex);
|
||||||
|
|
||||||
|
/* pass 0 = winner (reward, +1); pass 1 = loser (punish, -1). */
|
||||||
|
for (int pass = 0; pass < 2; ++pass) {
|
||||||
|
chess::Color side = chess::Color((pass == 0 ? winner : (winner ^ 1)) & 1);
|
||||||
|
int sign = pass == 0 ? 1 : -1;
|
||||||
|
|
||||||
|
for (int pt = chess::PAWN; pt <= chess::KING; ++pt)
|
||||||
|
for (int sq = 0; sq < 64; ++sq) {
|
||||||
|
if (t->mgOcc[side][pt][sq] != 0.0) {
|
||||||
|
int d = sign * int(std::lround(SQUARE_LR * t->mgOcc[side][pt][sq] * weight));
|
||||||
|
g_weights.mg[pt][sq] = std::clamp(g_weights.mg[pt][sq] + d, -SQ_CLAMP, SQ_CLAMP);
|
||||||
|
}
|
||||||
|
if (t->egOcc[side][pt][sq] != 0.0) {
|
||||||
|
int d = sign * int(std::lround(SQUARE_LR * t->egOcc[side][pt][sq] * weight));
|
||||||
|
g_weights.eg[pt][sq] = std::clamp(g_weights.eg[pt][sq] + d, -SQ_CLAMP, SQ_CLAMP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t->plies > 0)
|
||||||
|
for (int i = 0; i < FEATURE_NB; ++i) {
|
||||||
|
double avg = t->featAcc[side][i] / t->plies; /* per-ply average, normalized */
|
||||||
|
int d = sign * int(std::lround(FEAT_LR * (avg / FEAT_SCALE[i]) * weight));
|
||||||
|
g_weights.featW[i] = std::clamp(g_weights.featW[i] + d, -FEAT_CLAMP, FEAT_CLAMP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save_global_weights();
|
||||||
|
}
|
||||||
|
|
||||||
|
CHESS_API void CHESS_CALL trainer_destroy(TrainerHandle t) {
|
||||||
|
delete t; /* 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,108 @@
|
|||||||
|
// 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);
|
||||||
|
|
||||||
|
// Legal moves for a SPECIFIED color (for mobility eval of either side). When c is not
|
||||||
|
// the side to move, temporarily flips side-to-move (and clears the en-passant square,
|
||||||
|
// which belongs to the other side) so generate_legal runs for c, then restores. The
|
||||||
|
// Zobrist key is untouched and unused by move generation, so this leaves the position
|
||||||
|
// observably unchanged.
|
||||||
|
void generate_legal_for(Color c, MoveList& list) {
|
||||||
|
if (sideToMove == c) { generate_legal(list); return; }
|
||||||
|
Color savedSide = sideToMove;
|
||||||
|
Square savedEp = epSquare;
|
||||||
|
sideToMove = c;
|
||||||
|
epSquare = SQ_NONE;
|
||||||
|
generate_legal(list);
|
||||||
|
sideToMove = savedSide;
|
||||||
|
epSquare = savedEp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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