Author SHA1 Message Date
Josh-HeapsandClaude Opus 5 392b9ec212 Bound how long the blog cache serves stale entries
A failed fetch fell back to the cached value with no limit, so an unreachable
blog API kept the post list rendering while every uncached slug returned 404.
Stale entries now survive an hour past their TTL, then get dropped and logged
at error so the outage surfaces the same way for every key.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 17:22:51 -06:00
jheaps 769c7819e0 Merge pull request 'fix workflows take 2' (#6) from fix/workflows into master
Build and Deploy / build-deploy (push) Successful in 1m6s
Reviewed-on: #6
2026-08-26 15:46:12 -06:00
Josh-Heaps c048529654 fix workflows take 2 2026-08-26 15:45:29 -06:00
jheaps e8eb90a085 Merge pull request 'Put workflow in the right spot' (#4) from fix/workflows into master
Build and Deploy / build-deploy (push) Failing after 1m7s
Reviewed-on: #4
2026-08-26 15:39:47 -06:00
Josh-Heaps fc167ae2b0 Put workflow in the right spot 2026-08-26 15:39:11 -06:00
jheaps 088f05e0d2 Merge pull request 'add deploy workflow for gitea' (#3) from gitea/workflows into master
Build and Deploy / build (push) Failing after 34s
Build and Deploy / deploy (push) Skipped
Playwright Tests / playwright-tests (push) Failing after 50s
Reviewed-on: #3
2026-08-26 15:33:17 -06:00
Josh-Heaps 04dcae7993 add deploy workflow for gitea
.NET / build (pull_request) Successful in 50s
Playwright Tests / playwright-tests (pull_request) Failing after 54s
2026-08-26 15:15:51 -06:00
Josh Heaps f956f4bffa Merge pull request #29 from JoshHeaps/chess-ui-overhaul
Build and Deploy / build (push) Failing after 1m22s
Build and Deploy / deploy (push) Skipped
Playwright Tests / playwright-tests (push) Failing after 58s
Bug fixes
2026-06-13 22:03:35 -06:00
Josh-Heaps a53b0c2e43 Bug fixes 2026-06-13 22:01:12 -06:00
Josh Heaps edc4d70678 Merge pull request #28 from JoshHeaps/chess-ui-overhaul
change deploy
2026-06-13 21:34:22 -06:00
Josh-Heaps 3bfcbaa190 change deploy 2026-06-13 21:33:53 -06:00
Josh Heaps 59b318b75c Merge pull request #27 from JoshHeaps/chess-ui-overhaul
revert everything, these changes were a mistake
2026-06-13 20:30:57 -06:00
Josh-Heaps e5e0c9cc48 revert everything, these changes were a mistake 2026-06-13 20:30:26 -06:00
Josh Heaps 3947bd0daf Merge pull request #26 from JoshHeaps/chess-ui-overhaul
bug fixes
2026-06-13 20:14:20 -06:00
Josh-Heaps b99525ae51 fix deploy 2026-06-13 20:13:54 -06:00
Josh-Heaps 9da7b1986b bug fixes 2026-06-13 20:06:56 -06:00
Josh Heaps a0b8c2602a Merge pull request #25 from JoshHeaps/chess-ui-overhaul
fix deploy
2026-06-13 19:44:26 -06:00
Josh-Heaps 7604798e68 fix deploy 2026-06-13 19:43:40 -06:00
Josh Heaps 02163a4fc4 Merge pull request #24 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
2026-06-13 18:47:56 -06:00
Josh-Heaps 1680d13b05 bug fix 2026-06-13 18:47:31 -06:00
Josh-Heaps a1e2ad9c2d Bug fixes 2026-06-13 18:44:14 -06:00
Josh Heaps 26b4c0e59b Merge pull request #23 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
2026-06-13 17:32:16 -06:00
Josh-Heaps aeef320cb4 auto train 2026-06-13 17:30:09 -06:00
Josh-Heaps d71e485611 New learning strategy 2026-06-13 17:11:07 -06:00
Josh Heaps d09a067377 Merge pull request #22 from JoshHeaps/chess-ui-overhaul
Change data location
2026-06-12 19:53:34 -06:00
Josh-Heaps acbee436bb Change data location 2026-06-12 19:53:07 -06:00
Josh Heaps d452ee414b Merge pull request #21 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
2026-06-12 18:02:44 -06:00
Josh-Heaps f09ca60315 foo bar 2026-06-12 18:02:09 -06:00
Josh-Heaps 743c395e11 Choose color against cpu 2026-06-12 10:51:26 -06:00
Josh Heaps 62e146648c Merge pull request #20 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
2026-06-10 17:01:22 -06:00
Josh-HeapsandClaude Opus 4.8 6402ef268c Rework the chess page into a chess.com-style layout
Board with player bars and captured trays on the left, a game panel with
move list, status, and action buttons on the right. The page is locked to
the viewport (no scrolling); on phones the panel collapses into a slide-up
menu reachable from a slim status bar. Cache-busts the chess scripts too.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-10 16:58:35 -06:00
Josh-HeapsandClaude Opus 4.8 c92c46e6d4 Render the board from pushed state
Clients now render from the state delivered with each move/update and
drop stale or echoed updates via a ply-version guard, instead of issuing
a full game-state fetch per event. Adds captured-piece trays, a material
advantage badge, a move list, a status line, and the mobile menu toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-10 16:58:22 -06:00
Josh-HeapsandClaude Opus 4.8 e8a3bfe432 Push authoritative game state on every move
The move endpoint now returns the full resulting board state, and the
controller and CPU orchestrator broadcast it (plus captured pieces and a
ply version) over SignalR, so clients render from one payload instead of
re-fetching. Removes the client-driven hub relay.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-10 16:58:11 -06:00
Josh Heaps 8b78fd589c Merge pull request #19 from JoshHeaps/feature/customChessEngine
Add killer-move ordering and a search benchmark
2026-06-09 19:03:21 -06:00
Josh Heaps 7bfbae7301 Merge pull request #18 from JoshHeaps/feature/customChessEngine
Feature/custom chess engine
2026-06-08 19:15:41 -06:00
Josh Heaps 9b69489c4e Merge pull request #17 from JoshHeaps/feature/customChessEngine
Feature/custom chess engine
2026-06-08 14:17:52 -06:00
44 changed files with 2701 additions and 408 deletions
+97
View File
@@ -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"
+9 -1
View File
@@ -54,6 +54,12 @@ jobs:
name: site-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
run: |
install -m 700 -d ~/.ssh
@@ -63,7 +69,9 @@ jobs:
- name: Rsync to server
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 }}/
- name: Reload and restart service
+3
View File
@@ -3,6 +3,9 @@
##
## 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
*.rsuser
*.suo
+110 -148
View File
@@ -1,10 +1,9 @@
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using System.Collections.Concurrent;
namespace JoshHeaps.Net.Controllers;
@@ -15,58 +14,60 @@ public class ChessController(
IBackgroundTaskQueue queue,
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 _multiplayerGameTimeout = TimeSpan.FromDays(1);
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// Create a new chess game and store it in-memory.
/// </summary>
[HttpGet("new")]
[HttpGet("new/{difficulty}")]
public ActionResult CreateGame(int difficulty = 20)
public ActionResult CreateGame(int difficulty = 20, string color = "random")
{
var gameState = chessService.CreateNewGame();
_games[gameState.GameId] = gameState;
gameStore.Add(gameState);
gameState.IsVsComputer = true;
gameState.WhiteJoined = true;
gameState.BlackJoined = true;
Guid playerId = 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 = engineFactory.Create(difficulty);
var computer = engineFactory.Create(difficulty);
if (isWhite)
{
gameState.WhitePlayerId = playerId;
gameState.BlackPlayerId = computerId;
gameState.BlackComputer = computer;
}
else
{
gameState.WhitePlayerId = computerId;
gameState.BlackPlayerId = playerId;
gameState.WhiteComputer = computer;
queue.Queue(async () =>
{
// Give user's browser time to connect to signalR and such.
await Task.Delay(TimeSpan.FromSeconds(1));
await orchestrator.PlayAsync(gameState, gameState.Computer!);
await orchestrator.PlayAsync(gameState);
});
}
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
return Ok(new
{
@@ -77,28 +78,52 @@ public class ChessController(
}
/// <summary>
/// Create a game the computer plays against itself and auto-play it move by move,
/// broadcasting each move so it can be watched on the spectator page.
/// 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)
public ActionResult CreateSelfPlayGame(
int difficulty = 4,
string whiteEngine = "custom",
string blackEngine = "custom",
int? whiteSkill = null,
int? blackSkill = null)
{
var gameState = chessService.CreateNewGame();
_games[gameState.GameId] = gameState;
var config = new SelfPlayConfig(
ParseEngineKind(whiteEngine), whiteSkill ?? difficulty,
ParseEngineKind(blackEngine), blackSkill ?? difficulty);
gameState.IsVsComputer = true;
gameState.IsComputerVsComputer = true;
gameState.WhiteJoined = true;
gameState.BlackJoined = true;
gameState.WhitePlayerId = Guid.NewGuid();
gameState.BlackPlayerId = Guid.NewGuid();
gameState.Computer = engineFactory.Create(difficulty);
var (gameId, _) = selfPlay.StartGame(config);
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
StartSelfPlay(gameState);
return Ok(new { GameId = gameId });
}
return Ok(new { gameState.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>
@@ -110,12 +135,12 @@ public class ChessController(
public ActionResult JoinGame()
{
Console.WriteLine("joining game");
GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen);
GameState? gameState = gameStore.All.FirstOrDefault(g => g.IsOpen);
if (gameState == null)
{
gameState = chessService.CreateNewGame();
_games[gameState.GameId] = gameState;
gameStore.Add(gameState);
}
Guid playerId = Guid.NewGuid();
@@ -133,7 +158,7 @@ public class ChessController(
isWhite = false;
}
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
return Ok(new
{
@@ -149,7 +174,7 @@ public class ChessController(
[HttpGet("active")]
public ActionResult GetActiveGames()
{
var activeGames = _games.Values
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))
@@ -158,6 +183,8 @@ public class ChessController(
g.GameId,
g.IsVsComputer,
g.IsComputerVsComputer,
WhiteEngine = g.WhiteEngineKind.ToString(),
BlackEngine = g.BlackEngineKind.ToString(),
CurrentPlayer = g.CurrentPlayer.ToString(),
MoveCount = g.MoveHistory.Count,
g.IsCheck
@@ -167,42 +194,36 @@ public class ChessController(
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>
/// Get the state of an existing game by ID.
/// </summary>
[HttpGet("{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");
var response = new
{
gameState.GameId,
CurrentPlayer = gameState.CurrentPlayer.ToString(),
gameState.IsCheck,
gameState.IsCheckmate,
gameState.IsStalemate,
gameState.IsThreefoldRepetition,
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);
return Ok(gameState.ToDto());
}
/// <summary>
@@ -210,9 +231,9 @@ public class ChessController(
/// The test passes a JSON body with a MoveDto.
/// </summary>
[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");
// Check if player is authorized to move
@@ -239,16 +260,28 @@ public class ChessController(
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
if (isGameOver)
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
else if (gameState.IsVsComputer)
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
else
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
if (!isGameOver && gameState.IsVsComputer && gameState.Computer is not null)
queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!));
var state = gameState.ToDto();
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>
@@ -258,7 +291,7 @@ public class ChessController(
[HttpPost("forfeit")]
public async Task<ActionResult> Forfeit([FromBody] ForfeitDto forfeit)
{
if (!_games.TryGetValue(forfeit.GameId, out var gameState))
if (!gameStore.TryGet(forfeit.GameId, out var gameState))
return NotFound("Game not found");
if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited)
@@ -276,7 +309,7 @@ public class ChessController(
await chessHub.Clients.Group(gameState.GameId.ToString())
.SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit");
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
return Ok();
}
@@ -287,7 +320,7 @@ public class ChessController(
[HttpGet("{gameId}/pgn")]
public ActionResult GetPgn(Guid gameId)
{
if (!_games.TryGetValue(gameId, out var gameState))
if (!gameStore.TryGet(gameId, out var gameState))
return NotFound("Game not found");
return Content(gameState.ToPgn(), "application/x-chess-pgn");
@@ -299,7 +332,7 @@ public class ChessController(
[HttpGet("{gameId}/legalMoves/{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");
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
@@ -313,7 +346,7 @@ public class ChessController(
[HttpGet("{gameId}/legalMoves")]
public ActionResult GetAllLegalMoves(Guid gameId)
{
if (!_games.TryGetValue(gameId, out var gameState))
if (!gameStore.TryGet(gameId, out var gameState))
return NotFound("Game not found");
var allMoves = chessService.GetAllLegalMoves(gameState)
@@ -325,75 +358,4 @@ public class ChessController(
return Ok(allMoves);
}
/// <summary>
/// Drives a computer-vs-computer game: keeps asking the engine for the side-to-move's
/// move (which applies and broadcasts it) until the game ends or is removed.
/// </summary>
private void StartSelfPlay(GameState gameState)
{
queue.Queue(async () =>
{
// Give spectators a moment to join the SignalR group before the first move.
await Task.Delay(TimeSpan.FromSeconds(1));
while (_games.ContainsKey(gameState.GameId)
&& !gameState.IsCheckmate
&& !gameState.IsStalemate
&& !gameState.IsThreefoldRepetition
&& !gameState.IsForfeited)
{
try
{
await orchestrator.PlayAsync(gameState, gameState.Computer!);
}
catch (Exception ex)
{
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
break;
}
await Task.Delay(_selfPlayMoveDelay);
}
// Leave the finished game in place briefly so spectators can see the result.
if (_games.ContainsKey(gameState.GameId))
ScheduleRemoveGame(gameState.GameId, _selfPlayResultTimeout);
});
}
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
{
if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts))
{
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 -7
View File
@@ -1,5 +1,4 @@
using JoshHeaps.Net.Models;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR;
namespace JoshHeaps.Net.Hubs;
@@ -11,11 +10,6 @@ public class ChessHub : Hub
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)
{
Console.WriteLine($"❌ Leaving group {gameId}");
+16 -2
View File
@@ -1,4 +1,5 @@
using JoshHeaps.Net.Services.Interfaces;
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Models;
@@ -59,7 +60,20 @@ public class GameState
public bool IsVsComputer { get; set; } = false;
public bool IsComputerVsComputer { get; set; } = false;
public IChessEngine? 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
public bool IsOpen => !WhiteJoined || !BlackJoined;
+61
View File
@@ -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);
+59 -17
View File
@@ -6,7 +6,14 @@
}
<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">
<!-- Placeholder squares -->
@for (int i = 0; i < 64; i++)
@@ -14,18 +21,41 @@
<div id="square-@i" class="chessSquare @( (i + i / 8) % 2 == 0 ? "light" : "dark" )"></div>
}
</div>
</div>
<div id="textContainer" class="sideContent">
<div class="playerBar" id="bottomPlayerBar">
<span class="playerDot white"></span>
<span class="playerName">You</span>
<div class="capturedTray" id="captured-bottom"></div>
<span class="advantage" id="advantage-bottom"></span>
</div>
</section>
<aside id="gamePanel">
<header class="panelHeader">
<span class="panelLogo">&#9822;</span>
<h1>Chess</h1>
<p>Click a button to start a game :)</p>
<button id="menuClose" class="iconBtn" aria-label="Close menu" onclick="closeMenu()">&times;</button>
</header>
<div id="moveList">
<p class="movePlaceholder">Moves will appear here once a game begins.</p>
</div>
<div id="buttonContainer" class="sideContent">
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button>
<button id="copyPgnBtn" onclick="copyPgn()" style="display: none">Copy PGN</button>
<a id="watchLink" href="/watch">Watch other games →</a>
<div 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 &rarr;</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>
@@ -59,19 +89,31 @@
</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 {
<script src="~/js/signalr/signalr.min.js"></script>
<script src="~/js/ChessScripts/GameState.js"></script>
<script src="~/js/ChessScripts/ChessUtils.js"></script>
<script src="~/js/ChessScripts/ChessAPI.js"></script>
<script src="~/js/ChessScripts/ChessSignalR.js"></script>
<script src="~/js/ChessScripts/ChessInteractions.js"></script>
<script src="~/js/ChessScripts/ChessBoard.js"></script>
<script src="~/js/ChessScripts/ChessModals.js"></script>
<script src="~/js/ChessScripts/chessMain.js"></script>
<script src="~/js/ChessScripts/GameState.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessUtils.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessAPI.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessSignalR.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessInteractions.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessBoard.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessModals.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/chessMain.js?v=@ViewData["cssVersion"]"></script>
}
@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/site.css?v=@ViewData["cssVersion"]" />
}
+29 -2
View File
@@ -9,16 +9,43 @@
<h1>Live Chess</h1>
<p id="watchStatus">Loading games…</p>
<div id="watchControls">
<label for="cpuDifficulty">Difficulty</label>
<select id="cpuDifficulty">
<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>
+44
View File
@@ -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"]" />
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class WeightsModel : PageModel
{
public void OnGet()
{
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
@{
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>
+11
View File
@@ -23,12 +23,23 @@ builder.Services.AddSingleton<IChessService, ChessService>();
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())
{
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();
// Configure the HTTP request pipeline.
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 JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
@@ -10,6 +10,7 @@ public class BlogService : IBlogService
private readonly HttpClient _httpClient;
private readonly ILogger<BlogService> _logger;
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
private readonly TimeSpan _staleGrace = TimeSpan.FromHours(1);
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
{
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;
var result = await factory();
@@ -73,15 +76,26 @@ public class BlogService : IBlogService
if (result is not null)
{
_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
{
// API unreachable — serve stale cache
_logger.LogWarning("Serving stale cache for key {Key}", key);
if (entry.ExpiresAt.Add(_staleGrace) > DateTime.UtcNow)
{
_logger.LogWarning("Blog API unreachable, serving stale cache for key {Key}", key);
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()
@@ -7,7 +7,10 @@ namespace JoshHeaps.Net.Services.Implementations;
public enum ChessEngineKind
{
Stockfish,
Custom
Custom,
/// <summary>The custom engine with the reinforcement-learned piece-square evaluation.</summary>
CustomLearned
}
/// <summary>Configuration selecting which <see cref="IChessEngine"/> to use.</summary>
@@ -16,17 +19,46 @@ 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) : IChessEngineFactory
public sealed class ChessEngineFactory(
IOptions<ChessEngineOptions> options,
ILearnedWeightsStore weightsStore) : IChessEngineFactory
{
private readonly ChessEngineKind _kind = options.Value.Engine;
public IChessEngine Create(int skill) => _kind switch
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}'.")
_ => throw new InvalidOperationException($"Unknown chess engine '{kind}'.")
};
}
@@ -11,23 +11,60 @@ namespace JoshHeaps.Net.Services.Implementations;
/// </summary>
public sealed class ComputerMoveOrchestrator(
IHubContext<ChessHub> chessHub,
IChessService chessService) : IComputerMoveOrchestrator
IChessService chessService,
ILearnedWeightsStore weightsStore) : IComputerMoveOrchestrator
{
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine)
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));
var move = uci.ToMoveDto(
state,
state.CurrentPlayer == PieceColor.White
? state.WhitePlayerId
: state.BlackPlayerId);
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);
.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;
}
@@ -5,6 +5,16 @@ 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.
@@ -16,11 +26,17 @@ public sealed partial class CustomChessEngine : IChessEngine
public int Skill { get; }
public CustomChessEngine(int skill = 20)
public CustomChessEngine(int skill = 20, EngineVariant variant = EngineVariant.Classic, string? weightsPath = null)
{
Skill = skill;
var handle = NativeMethods.engine_create($"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).");
@@ -79,8 +95,9 @@ public sealed partial class CustomChessEngine : IChessEngine
/// <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>
private static partial class NativeMethods
internal static partial class NativeMethods
{
private const string LibName = "chess_engine";
@@ -115,5 +132,31 @@ public sealed partial class CustomChessEngine : IChessEngine
[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,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,3 +1,5 @@
using JoshHeaps.Net.Services.Implementations;
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>
@@ -6,9 +8,20 @@ namespace JoshHeaps.Net.Services.Interfaces;
public interface IChessEngineFactory
{
/// <summary>
/// Creates a new engine instance for a single game. The caller owns and disposes it.
/// 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);
}
@@ -3,16 +3,24 @@ using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>
/// Drives a computer move: asks the engine for a move, applies it through the rules
/// service, and broadcasts the result to the game's clients.
/// 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 engine pick a move for the current position, applies it, and broadcasts it.
/// 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>
/// <param name="engine">The engine that selects the move.</param>
/// <returns>The applied move and its result.</returns>
Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine);
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);
}
+42 -9
View File
@@ -1,12 +1,4 @@
#boardContainer {
position: relative;
width: fit-content;
margin: 2vw auto;
}
#chessBoard {
width: 60vw;
height: 60vw;
#chessBoard {
display: grid;
grid-template-columns: repeat(8, 1fr);
grid-template-rows: repeat(8, 1fr);
@@ -165,6 +157,47 @@
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 {
position: absolute;
font-size: 1.2vw;
+395 -125
View File
@@ -1,159 +1,429 @@
html, body {
background-color: #2b2c30;
color: #d6d6d6;
cursor: default;
:root {
--bg: #2b2c30;
--panel: #21232a;
--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%;
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 {
background-color: #8cd5ed;
color: #262626;
border-radius: 30px;
border: 0px;
cursor: pointer;
order: 1;
padding: 2vh;
margin: 2vh;
main {
height: 100%;
}
#startCPUGame {
background-color: #8cd5ed;
color: #262626;
border-radius: 30px;
border: 0px;
cursor: pointer;
order: 1;
padding: 2vh;
margin: 2vh;
}
#copyPgnBtn {
background-color: #8cd5ed;
color: #262626;
border-radius: 30px;
border: 0px;
cursor: pointer;
order: 1;
padding: 2vh;
margin: 2vh;
}
#watchLink {
color: #8cd5ed;
text-decoration: none;
margin: 2vh;
order: 2;
}
#watchLink:hover {
text-decoration: underline;
}
/* ---- Layout ------------------------------------------------------------ */
/* Portrait / narrow: board on top, panel stacked below. */
#chessContainer {
height: 100vh;
height: 100dvh;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-evenly;
height: 100vh;
box-sizing: border-box;
padding: 5vw;
gap: clamp(8px, 1.6vh, 16px);
padding: clamp(10px, 2vh, 20px);
overflow: hidden;
}
#boardArea {
display: flex;
flex-direction: column;
gap: 6px;
flex: 0 0 auto;
width: min(94vw, 56vh);
}
#chessBoard {
aspect-ratio: 1 / 1;
width: 90vw; /* use the smaller of width or height */
width: 100%;
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 {
align-content: center;
flex-grow: 1;
}
.sideContent {
#gamePanel {
width: min(94vw, 56vh);
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
align-content: center;
justify-content: center;
text-align: center;
background: var(--panel);
border-radius: var(--radius);
overflow: hidden;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
}
#textContainer {
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 */
/* Landscape / wide: board left, game panel right (chess.com style). */
@media (min-aspect-ratio: 1/1) {
#chessContainer {
display: grid;
grid-template-columns: auto, auto;
grid-template-rows: auto, auto;
align-items: center;
flex-direction: row;
align-items: center;
justify-content: center;
align-items: center;
height: 100vh;
padding: 0;
gap: clamp(16px, 3vw, 48px);
padding: clamp(12px, 3vh, 28px);
}
.sideContent {
display: flex;
flex-direction: column;
flex-grow: 1;
flex-shrink: 0;
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;
#boardArea {
width: auto;
height: 100%;
justify-content: center;
flex: 0 0 auto;
}
#chessBoard {
aspect-ratio: 1 / 1;
flex-shrink: 1;
width: min(90vh, 90vw);
max-height: 90vh;
width: min(72vh, 54vw);
height: min(72vh, 54vw);
}
#difficultyButtonContainer {
grid-template-columns: repeat(10, 1fr);
.playerBar {
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;
}
}
+18 -2
View File
@@ -21,11 +21,27 @@ html, body {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
flex-wrap: wrap;
gap: 0.75rem;
margin: 1rem 0;
}
#cpuDifficulty {
.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;
+187
View File
@@ -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();
},
async createCPUGame(difficulty) {
const response = await fetch(`/api/chess/new/${difficulty}`);
async createCPUGame(difficulty, color = "random") {
const response = await fetch(`/api/chess/new/${difficulty}?color=${color}`);
return await response.json();
},
@@ -46,13 +46,15 @@ const ChessAPI = {
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) {
throw new Error(result.message || "Invalid move or not your turn.");
if (!data.result.success) {
throw new Error(data.result.message || "Invalid move or not your turn.");
}
return result;
return data;
},
async handleMove(targetRow, targetCol) {
@@ -85,16 +87,14 @@ const ChessAPI = {
GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]);
try {
const moveResult = await this.makeMove(moveDto);
const { result, state } = await this.makeMove(moveDto);
const updatedGame = await this.getGameState(GameState.currentGameId);
ChessBoard.renderPieces(updatedGame.pieces);
ChessBoard.renderState(state);
GameState.clearSelection();
ChessInteractions.clearHighlights();
await ChessSignalR.notifyMoveMade(moveDto, moveResult);
this.alertGameStatusChange(moveResult);
this.alertGameStatusChange(result);
} catch (error) {
alert("❌ " + error.message);
@@ -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 = {
// 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) {
this.clearAllSquares();
this.renderCoordinateLabels();
@@ -7,6 +94,48 @@ const ChessBoard = {
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() {
for (let i = 0; i < 64; i++) {
const square = document.getElementById(`square-${i}`);
@@ -23,6 +23,16 @@ const ChessModals = {
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,8 @@ const ChessSignalR = {
console.error("❌ SignalR connection closed:", err?.message);
});
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto) => {
await this.handleMoveUpdate(gameId, moveDto, moveResultDto);
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto, state) => {
await this.handleMoveUpdate(gameId, moveDto, moveResultDto, state);
});
this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => {
@@ -28,12 +28,14 @@ const ChessSignalR = {
}
},
async handleMoveUpdate(gameId, moveDto, moveResultDto) {
async handleMoveUpdate(gameId, moveDto, moveResultDto, state) {
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]);
ChessBoard.renderPieces(gameState.pieces);
ChessBoard.renderState(state);
ChessAPI.alertGameStatusChange(moveResultDto);
},
@@ -50,12 +52,6 @@ const ChessSignalR = {
await this.leaveGame();
},
async notifyMoveMade(moveDto, moveResult) {
if (this.connection) {
await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult);
}
},
async leaveGame() {
if (this.connection) {
await this.connection.invoke("LeaveWebsocketGroup", GameState.currentGameId);
@@ -6,6 +6,10 @@ const GameState = {
legalMoves: [],
previousMoveStart: 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() {
this.currentGameId = null;
@@ -15,12 +19,22 @@ const GameState = {
this.legalMoves = [];
this.previousMoveStart = 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) {
this.currentGameId = gameId;
this.currentPlayerId = playerId;
this.currentPlayerIsWhite = isWhite;
this.lastVersion = -1;
},
setSelectedPiece(piece) {
@@ -11,8 +11,8 @@ const Spectate = {
.configureLogging(signalR.LogLevel.Warning)
.build();
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto) =>
this.handleMoveUpdate(gameId, moveDto));
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto, _moveResult, state) =>
this.handleMoveUpdate(gameId, moveDto, state));
this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId));
@@ -23,14 +23,50 @@ const Spectate = {
}
await this.refreshGames();
await this.loadAutoTrainCount();
setInterval(() => this.refreshGames(), 5000);
},
async startCpuGame() {
const difficulty = document.getElementById("cpuDifficulty").value;
// 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 {
await fetch(`/api/chess/watch/cpu/${difficulty}`);
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);
@@ -68,7 +104,9 @@ const Spectate = {
async addGame(game) {
this.games.set(game.gameId, {
isVsComputer: game.isVsComputer,
isComputerVsComputer: game.isComputerVsComputer
isComputerVsComputer: game.isComputerVsComputer,
whiteEngine: game.whiteEngine,
blackEngine: game.blackEngine
});
const card = document.createElement("div");
@@ -117,7 +155,10 @@ const Spectate = {
if (!response.ok) return;
const state = await response.json();
this.renderFromState(gameId, await response.json());
},
renderFromState(gameId, state) {
const result = this.resultTextFromState(state);
const stored = this.games.get(gameId);
@@ -128,10 +169,13 @@ const Spectate = {
this.setResult(gameId, result);
},
async handleMoveUpdate(gameId, moveDto) {
async handleMoveUpdate(gameId, moveDto, state) {
if (!this.games.has(gameId)) return;
await this.renderGame(gameId);
// 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);
},
@@ -177,11 +221,21 @@ const Spectate = {
},
gameLabel(stored) {
if (stored.isComputerVsComputer) return "CPU vs CPU";
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`;
@@ -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");
@@ -30,6 +30,18 @@ function resetCopyPgn() {
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");
@@ -44,6 +56,7 @@ async function forfeitCurrentGame() {
}
async function startNewGame() {
closeMenu();
await ChessSignalR.stopConnection();
await forfeitCurrentGame();
resetCopyPgn();
@@ -63,7 +76,7 @@ async function startNewGame() {
await ChessSignalR.setupConnection();
const gameState = await ChessAPI.getGameState(gameData.gameId);
ChessBoard.renderPieces(gameState.pieces);
ChessBoard.renderState(gameState);
} catch (error) {
console.error("Failed to start new game:", error);
@@ -71,13 +84,15 @@ async function startNewGame() {
}
async function startCPUGame() {
closeMenu();
await ChessSignalR.stopConnection();
await forfeitCurrentGame();
resetCopyPgn();
try {
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.clearPreviousMove();
@@ -91,7 +106,7 @@ async function startCPUGame() {
await ChessSignalR.setupConnection();
const gameState = await ChessAPI.getGameState(gameData.gameId);
ChessBoard.renderPieces(gameState.pieces);
ChessBoard.renderState(gameState);
} catch (error) {
console.error("Failed to start CPU game:", error);
@@ -111,7 +126,7 @@ async function resumeSavedGame() {
GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true");
await ChessSignalR.setupConnection();
ChessBoard.renderPieces(gameState.pieces);
ChessBoard.renderState(gameState);
} catch (err) {
console.warn("Saved game not found or expired.", err);
@@ -32,6 +32,9 @@ extern "C" { /* prevent C++ name mangling */
* 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,
@@ -79,6 +82,32 @@ 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
+357 -20
View File
@@ -20,10 +20,12 @@
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <mutex>
#include <new>
#include <string>
@@ -76,10 +78,63 @@ static chess::Move tt_move (uint64_t d) { return chess::Move(static_cast<uint16_
static int tt_depth(uint64_t d) { return static_cast<int>(static_cast<uint8_t>(d >> 48)); }
static Bound tt_bound(uint64_t d) { return static_cast<Bound>(static_cast<uint8_t>(d >> 56)); }
/* Internal engine state. One ChessEngine = one game. The table is NOT here: it is the
* shared g_tt above. */
/* 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) {
@@ -108,6 +163,48 @@ static int parse_skill(const char* options, int fallback) {
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) {
@@ -208,14 +305,32 @@ static int evaluatePawn(const chess::Position& pos, const chess::Color c, const
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) {
if (!pos.pieces(chess::QUEEN))
return 0;
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 ? 600 : 0;
return castled ? (total / 10) : 0;
}
static int evaluatePiece(const chess::Position& pos, const chess::Square& s, const chess::Piece& pc, const chess::Color& c) {
@@ -261,10 +376,136 @@ static int evaluate(const chess::Position& pos) {
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 (×(1phase)) 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(const chess::Position& pos, bool whiteToMove) {
int s = evaluate(pos);
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;
}
@@ -274,17 +515,6 @@ static int evaluate_stm(const chess::Position& pos, bool whiteToMove) {
static int score_to_tt(int s, int ply) { return s >= MATE_BOUND ? s + ply : s <= -MATE_BOUND ? s - ply : s; }
static int score_from_tt(int s, int ply) { return s >= MATE_BOUND ? s - ply : s <= -MATE_BOUND ? s + ply : s; }
static int piece_value(chess::PieceType pt) {
switch (pt) {
case chess::PAWN: return 100;
case chess::KNIGHT: return 320;
case chess::BISHOP: return 330;
case chess::ROOK: return 500;
case chess::QUEEN: return 900;
default: return 0;
}
}
/* Heuristic for searching the most promising moves first, which makes alpha-beta prune far
* more. Bands, highest first: the TT best move, then captures by MVV-LVA (most valuable
* victim, least valuable attacker), then the two killer moves for this ply (quiet moves that
@@ -351,6 +581,15 @@ CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) {
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;
}
@@ -370,7 +609,8 @@ static constexpr int MAX_PLY = 128; /* ply never exceeds maxDepth (<=
struct SearchContext {
uint64_t nodes = 0;
chess::Move killers[MAX_PLY][2] = {}; /* [ply][slot]; MOVE_NONE until filled */
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
@@ -387,7 +627,7 @@ static int negamax(chess::Position& pos, int maxDepth, int depth, int ply,
return 0;
if (depth <= 0)
return evaluate_stm(pos, whiteToMove);
return evaluate_stm(pos, whiteToMove, *ctx.eval);
const uint64_t key = pos.key();
TTEntry& slot = g_tt.entries[key & g_tt.mask];
@@ -502,6 +742,7 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
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 */
@@ -547,4 +788,100 @@ 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" */
+16
View File
@@ -47,6 +47,22 @@ public:
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.