diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f3a189f..67e3868 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index 350d8f2..48daacf 100644 --- a/JoshHeaps.Net/Controllers/ChessController.cs +++ b/JoshHeaps.Net/Controllers/ChessController.cs @@ -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; @@ -16,24 +15,14 @@ public class ChessController( IChessEngineFactory engineFactory, IComputerMoveOrchestrator orchestrator, ILearnedWeightsStore weightsStore, + IGameStore gameStore, + ISelfPlayCoordinator selfPlay, + AutoTrainingSettings autoTraining, IHubContext chessHub) : ControllerBase { - /// - /// Store of ongoing games. - /// - private static readonly ConcurrentDictionary _games = []; - private static readonly ConcurrentDictionary _gameRemovalTasks = []; - private static readonly ConcurrentDictionary _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); - - // 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; /// /// Create a new chess game and store it in-memory. @@ -43,7 +32,7 @@ public class ChessController( 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; @@ -78,7 +67,7 @@ public class ChessController( }); } - ScheduleRemoveGame(gameState.GameId, _computerGameTimeout); + gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout); return Ok(new { @@ -102,31 +91,13 @@ public class ChessController( int? whiteSkill = null, int? blackSkill = null) { - var whiteKind = ParseEngineKind(whiteEngine); - var blackKind = ParseEngineKind(blackEngine); + var config = new SelfPlayConfig( + ParseEngineKind(whiteEngine), whiteSkill ?? difficulty, + ParseEngineKind(blackEngine), blackSkill ?? difficulty); - var gameState = chessService.CreateNewGame(); - _games[gameState.GameId] = gameState; + var (gameId, _) = selfPlay.StartGame(config); - gameState.IsVsComputer = true; - gameState.IsComputerVsComputer = true; - gameState.WhiteJoined = true; - gameState.BlackJoined = true; - gameState.WhitePlayerId = Guid.NewGuid(); - gameState.BlackPlayerId = Guid.NewGuid(); - gameState.WhiteEngineKind = whiteKind; - gameState.BlackEngineKind = blackKind; - gameState.WhiteComputer = engineFactory.Create(whiteSkill ?? difficulty, whiteKind); - gameState.BlackComputer = engineFactory.Create(blackSkill ?? difficulty, blackKind); - - // When the learned engine is playing, attach a trainer so the outcome can train it. - if (whiteKind == ChessEngineKind.CustomLearned || blackKind == ChessEngineKind.CustomLearned) - gameState.Trainer = weightsStore.CreateTrainer(); - - ScheduleRemoveGame(gameState.GameId, _computerGameTimeout); - StartSelfPlay(gameState); - - return Ok(new { gameState.GameId }); + return Ok(new { GameId = gameId }); } private static ChessEngineKind ParseEngineKind(string value) => value.ToLowerInvariant() switch @@ -136,6 +107,25 @@ public class ChessController( _ => ChessEngineKind.Custom }; + /// + /// 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. + /// + [HttpGet("autotrain")] + public ActionResult GetAutoTrain() => + Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames }); + + /// + /// 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. + /// + [HttpPost("autotrain")] + public ActionResult SetAutoTrain([FromQuery] int count) + { + autoTraining.GameCount = count; // clamped inside the setter + return Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames }); + } + /// /// Joins the "pool" of chess players. /// Test code expects to receive a GUID for the player @@ -145,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(); @@ -168,7 +158,7 @@ public class ChessController( isWhite = false; } - ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout); + gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout); return Ok(new { @@ -184,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)) @@ -230,20 +220,20 @@ public class ChessController( [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"); return Ok(gameState.ToDto()); } /// - /// Make a move in the specified game. + /// Make a move in the specified game. /// The test passes a JSON body with a MoveDto. /// [HttpPost("move")] public async Task 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 @@ -270,11 +260,11 @@ 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); var state = gameState.ToDto(); @@ -301,7 +291,7 @@ public class ChessController( [HttpPost("forfeit")] public async Task 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) @@ -319,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(); } @@ -330,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"); @@ -342,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); @@ -356,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) @@ -368,181 +358,4 @@ public class ChessController( return Ok(allMoves); } - - /// - /// Drives a computer-vs-computer game: keeps asking the side-to-move's engine for its - /// move (which applies and broadcasts it) until the game ends or is removed. Training - /// games get a randomized opening and feed their result back into the learned weights. - /// - 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)); - - // 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 && _games.ContainsKey(gameState.GameId) && !IsGameOver(gameState); i++) - { - await orchestrator.PlayRandomMoveAsync(gameState); - await Task.Delay(_selfPlayMoveDelay); - } - - while (_games.ContainsKey(gameState.GameId) && !IsGameOver(gameState)) - { - try - { - await orchestrator.PlayAsync(gameState); - } - catch (Exception ex) - { - Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}"); - break; - } - - await Task.Delay(_selfPlayMoveDelay); - } - - ApplyLearning(gameState); - - // Leave the finished game in place briefly so spectators can see the result. - if (_games.ContainsKey(gameState.GameId)) - ScheduleRemoveGame(gameState.GameId, _selfPlayResultTimeout); - }); - } - - private static bool IsGameOver(GameState gameState) => - gameState.IsCheckmate || gameState.IsStalemate || gameState.IsThreefoldRepetition || gameState.IsForfeited; - - /// - /// Feeds a finished training game's result into the learned weights, then frees the - /// trainer. Both sides teach the table — the winner's squares/features up, the loser's - /// down. 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 (but the trainer is still freed). - /// - 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; - } - - /// - /// 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). - /// - 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 - } - - /// Total non-king material per side (P=1, N=B=3, R=5, Q=9), for draw adjudication. - 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); - } - - 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)) - { - 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). - // The native ABI is shared via CustomChessEngine's import resolver. - if (game.Trainer != nint.Zero) - { - CustomChessEngine.NativeMethods.trainer_destroy(game.Trainer); - game.Trainer = nint.Zero; - } - } - - _games.Remove(id, out _); - } - catch (OperationCanceledException) { } - finally - { - if (_gameRemovalCancellationTokens.TryGetValue(id, out var currentCts) && currentCts == cts) - { - _gameRemovalCancellationTokens.TryRemove(id, out _); - } - - cts.Dispose(); - } - }); - } } diff --git a/JoshHeaps.Net/Pages/Watch.cshtml b/JoshHeaps.Net/Pages/Watch.cshtml index 3e04add..b4718e9 100644 --- a/JoshHeaps.Net/Pages/Watch.cshtml +++ b/JoshHeaps.Net/Pages/Watch.cshtml @@ -38,6 +38,11 @@ +
+ Auto-train games + + +
← Play a game View learned weights → diff --git a/JoshHeaps.Net/Program.cs b/JoshHeaps.Net/Program.cs index 0657f4e..cd006d7 100644 --- a/JoshHeaps.Net/Program.cs +++ b/JoshHeaps.Net/Program.cs @@ -26,10 +26,20 @@ builder.Services.Configure(configuration.GetSection(ChessEng builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); if (!builder.Environment.IsDevelopment()) +{ builder.Services.AddHostedService(); + // 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(); +} + var app = builder.Build(); // Configure the HTTP request pipeline. diff --git a/JoshHeaps.Net/Services/Implementations/AutoTrainingService.cs b/JoshHeaps.Net/Services/Implementations/AutoTrainingService.cs new file mode 100644 index 0000000..607aeec --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/AutoTrainingService.cs @@ -0,0 +1,73 @@ +using JoshHeaps.Net.Services.Interfaces; + +namespace JoshHeaps.Net.Services.Implementations; + +/// +/// 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 +/// (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. +/// +public sealed class AutoTrainingService( + ISelfPlayCoordinator coordinator, + AutoTrainingSettings settings, + ILogger 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(); + 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; } + } + } +} diff --git a/JoshHeaps.Net/Services/Implementations/AutoTrainingSettings.cs b/JoshHeaps.Net/Services/Implementations/AutoTrainingSettings.cs new file mode 100644 index 0000000..16e605f --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/AutoTrainingSettings.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Options; + +namespace JoshHeaps.Net.Services.Implementations; + +/// +/// Runtime-adjustable auto-training settings. Singleton so the value set from the website (via the +/// chess controller) is seen live by the background . Seeded from +/// and clamped to a sane range. +/// +public sealed class AutoTrainingSettings +{ + /// Upper bound on concurrent auto-training games (each spawns a Stockfish + a learned engine). + public const int MaxGames = 16; + + private int _gameCount; + + public AutoTrainingSettings(IOptions options) + => _gameCount = Clamp(options.Value.AutoTrainGameCount); + + /// + /// Number of auto-training games to keep running concurrently. 0 pauses auto-training. + /// Reads/writes are atomic; the background service reads this every cycle. + /// + 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); +} diff --git a/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs b/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs index 6d18441..d0833a8 100644 --- a/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs +++ b/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs @@ -29,6 +29,20 @@ public sealed class ChessEngineOptions /// clashes. Override via the ChessEngine__WeightsPath environment variable. ///
public string? WeightsPath { get; set; } + + /// + /// 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 ChessEngine__AutoTrain environment variable. + /// + public bool AutoTrain { get; set; } = true; + + /// + /// How many auto-training games run concurrently (when is on). This is + /// the starting value; it can be changed at runtime from the website. Override the default via + /// the ChessEngine__AutoTrainGameCount environment variable. + /// + public int AutoTrainGameCount { get; set; } = 2; } /// Creates the configured per game. diff --git a/JoshHeaps.Net/Services/Implementations/GameStore.cs b/JoshHeaps.Net/Services/Implementations/GameStore.cs new file mode 100644 index 0000000..af85de0 --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/GameStore.cs @@ -0,0 +1,69 @@ +using System.Collections.Concurrent; +using JoshHeaps.Net.Models; +using JoshHeaps.Net.Services.Interfaces; + +namespace JoshHeaps.Net.Services.Implementations; + +/// +/// 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. +/// +public sealed class GameStore(ILearnedWeightsStore weightsStore) : IGameStore +{ + private readonly ConcurrentDictionary _games = []; + private readonly ConcurrentDictionary _removalTasks = []; + private readonly ConcurrentDictionary _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 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(); + } + }); + } +} diff --git a/JoshHeaps.Net/Services/Implementations/SelfPlayCoordinator.cs b/JoshHeaps.Net/Services/Implementations/SelfPlayCoordinator.cs new file mode 100644 index 0000000..d995f3c --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/SelfPlayCoordinator.cs @@ -0,0 +1,257 @@ +using JoshHeaps.Net.Models; +using JoshHeaps.Net.Services.Interfaces; + +namespace JoshHeaps.Net.Services.Implementations; + +/// +/// 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. +/// +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); + } + + /// + /// 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. + /// + 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); + } + + /// + /// Plays one engine move, abandoning it if it exceeds (throwing + /// ) 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. + /// + 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; + + /// + /// 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. + /// + 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; + } + + /// + /// Frees an abandoned game's trainer without recording any result — a cancelled, crashed, or + /// idle-timed-out game teaches the model nothing. + /// + private void DiscardTraining(GameState gameState) + { + if (gameState.Trainer == nint.Zero) + return; + + weightsStore.DestroyTrainer(gameState.Trainer); + gameState.Trainer = nint.Zero; + } + + /// + /// 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). + /// + 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 + } + + /// Total non-king material per side (P=1, N=B=3, R=5, Q=9), for draw adjudication. + 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); + } +} diff --git a/JoshHeaps.Net/Services/Interfaces/IGameStore.cs b/JoshHeaps.Net/Services/Interfaces/IGameStore.cs new file mode 100644 index 0000000..1794fff --- /dev/null +++ b/JoshHeaps.Net/Services/Interfaces/IGameStore.cs @@ -0,0 +1,29 @@ +using JoshHeaps.Net.Models; + +namespace JoshHeaps.Net.Services.Interfaces; + +/// +/// 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. +/// +public interface IGameStore +{ + /// Add (or replace) a game in the registry. + void Add(GameState game); + + /// Look a game up by id. + bool TryGet(Guid id, out GameState game); + + /// Whether a game with this id is still in the registry. + bool Contains(Guid id); + + /// Snapshot of all games currently in the registry. + IReadOnlyCollection All { get; } + + /// + /// Schedule removal of a game after , cancelling any prior schedule + /// for it. On removal the game's engines are disposed and any training accumulator freed. + /// + void ScheduleRemove(Guid id, TimeSpan delay); +} diff --git a/JoshHeaps.Net/Services/Interfaces/ISelfPlayCoordinator.cs b/JoshHeaps.Net/Services/Interfaces/ISelfPlayCoordinator.cs new file mode 100644 index 0000000..fc17da9 --- /dev/null +++ b/JoshHeaps.Net/Services/Interfaces/ISelfPlayCoordinator.cs @@ -0,0 +1,23 @@ +using JoshHeaps.Net.Services.Implementations; + +namespace JoshHeaps.Net.Services.Interfaces; + +/// Per-side engine and strength for a CPU-vs-CPU game. +public sealed record SelfPlayConfig( + ChessEngineKind WhiteKind, int WhiteSkill, + ChessEngineKind BlackKind, int BlackSkill); + +/// +/// 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. +/// +public interface ISelfPlayCoordinator +{ + /// + /// 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. + /// + (Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default); +} diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js index d576798..35fa15e 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js @@ -23,9 +23,40 @@ const Spectate = { } await this.refreshGames(); + await this.loadAutoTrainCount(); setInterval(() => this.refreshGames(), 5000); }, + // Auto-training runs server-side; show its target count and let it be changed here. + async loadAutoTrainCount() { + const input = document.getElementById("autoTrainCount"); + if (!input) return; + + try { + const response = await fetch("/api/chess/autotrain"); + const data = await response.json(); + input.max = data.max; + // Don't clobber the value while the user is editing it. + if (document.activeElement !== input) + input.value = data.count; + } catch { + // Leave the control as-is if auto-training status can't be read. + } + }, + + async setAutoTrainCount() { + const input = document.getElementById("autoTrainCount"); + const count = Math.max(0, parseInt(input.value, 10) || 0); + + try { + const response = await fetch(`/api/chess/autotrain?count=${count}`, { method: "POST" }); + const data = await response.json(); + input.value = data.count; + } catch (err) { + console.error("❌ Could not set the auto-training game count.", err); + } + }, + async startCpuGame() { const params = new URLSearchParams({ whiteEngine: document.getElementById("whiteEngine").value,