From aeef320cb432fe45d22e6d7b10eeb15ee9f69ab4 Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Sat, 13 Jun 2026 17:30:09 -0600 Subject: [PATCH] auto train --- JoshHeaps.Net/Controllers/ChessController.cs | 252 ++---------------- JoshHeaps.Net/Program.cs | 9 + .../Implementations/AutoTrainingService.cs | 55 ++++ .../Implementations/ChessEngineFactory.cs | 7 + .../Services/Implementations/GameStore.cs | 69 +++++ .../Implementations/SelfPlayCoordinator.cs | 189 +++++++++++++ .../Services/Interfaces/IGameStore.cs | 29 ++ .../Interfaces/ISelfPlayCoordinator.cs | 23 ++ 8 files changed, 404 insertions(+), 229 deletions(-) create mode 100644 JoshHeaps.Net/Services/Implementations/AutoTrainingService.cs create mode 100644 JoshHeaps.Net/Services/Implementations/GameStore.cs create mode 100644 JoshHeaps.Net/Services/Implementations/SelfPlayCoordinator.cs create mode 100644 JoshHeaps.Net/Services/Interfaces/IGameStore.cs create mode 100644 JoshHeaps.Net/Services/Interfaces/ISelfPlayCoordinator.cs diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index fa31b5d..9f5f7c1 100644 --- a/JoshHeaps.Net/Controllers/ChessController.cs +++ b/JoshHeaps.Net/Controllers/ChessController.cs @@ -4,7 +4,6 @@ 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,13 @@ public class ChessController( IChessEngineFactory engineFactory, IComputerMoveOrchestrator orchestrator, ILearnedWeightsStore weightsStore, + IGameStore gameStore, + ISelfPlayCoordinator selfPlay, 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 +31,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 +66,7 @@ public class ChessController( }); } - ScheduleRemoveGame(gameState.GameId, _computerGameTimeout); + gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout); return Ok(new { @@ -102,31 +90,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 @@ -145,12 +115,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 +138,7 @@ public class ChessController( isWhite = false; } - ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout); + gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout); return Ok(new { @@ -184,7 +154,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,7 +200,7 @@ 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()); @@ -243,7 +213,7 @@ public class ChessController( [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 +240,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 +271,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 +289,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 +300,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 +312,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 +326,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) @@ -369,180 +339,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/Program.cs b/JoshHeaps.Net/Program.cs index 0657f4e..936efe1 100644 --- a/JoshHeaps.Net/Program.cs +++ b/JoshHeaps.Net/Program.cs @@ -26,10 +26,19 @@ builder.Services.Configure(configuration.GetSection(ChessEng 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..6b5c2f3 --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/AutoTrainingService.cs @@ -0,0 +1,55 @@ +using JoshHeaps.Net.Services.Interfaces; + +namespace JoshHeaps.Net.Services.Implementations; + +/// +/// Continuously trains the learned engine in the background: two self-play games run in +/// parallel, the learned engine (skill 6) against Stockfish (skill 20), one with Stockfish as +/// black and one as white. Each slot is independent — when its game finishes it immediately +/// starts another under the same conditions, without waiting on the other slot. Registered +/// only outside Development and gated by the ChessEngine:AutoTrain config flag. +/// +public sealed class AutoTrainingService( + ISelfPlayCoordinator coordinator, + ILogger logger) : BackgroundService +{ + private const int LearnedSkill = 6; + private const int StockfishSkill = 20; + private static readonly TimeSpan _restartBackoff = TimeSpan.FromSeconds(5); + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + // Learned plays both colors so the model trains symmetrically; each slot is its own loop. + var stockfishBlack = RunSlot( + new SelfPlayConfig(ChessEngineKind.CustomLearned, LearnedSkill, ChessEngineKind.Stockfish, StockfishSkill), + stoppingToken); + var stockfishWhite = RunSlot( + new SelfPlayConfig(ChessEngineKind.Stockfish, StockfishSkill, ChessEngineKind.CustomLearned, LearnedSkill), + stoppingToken); + + return Task.WhenAll(stockfishBlack, stockfishWhite); + } + + private async Task RunSlot(SelfPlayConfig config, CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await coordinator.StartGame(config, stoppingToken).Completion; + } + catch (OperationCanceledException) + { + break; + } + 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, "Auto-training game failed to start; retrying after backoff."); + try { await Task.Delay(_restartBackoff, stoppingToken); } + catch (OperationCanceledException) { break; } + } + } + } +} diff --git a/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs b/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs index 6d18441..8fcad4c 100644 --- a/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs +++ b/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs @@ -29,6 +29,13 @@ 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; } /// 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..6cca76f --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/SelfPlayCoordinator.cs @@ -0,0 +1,189 @@ +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); + + // 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 just ends + /// the game (and the trainer is always freed), so callers can safely await or ignore it. + /// + private async Task RunAsync(GameState gameState, CancellationToken cancellationToken) + { + 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); + } + + while (IsLive(gameState, cancellationToken)) + { + await orchestrator.PlayAsync(gameState); + await Task.Delay(_selfPlayMoveDelay, cancellationToken); + } + } + catch (OperationCanceledException) { /* service shutting down */ } + catch (Exception ex) + { + Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}"); + } + + ApplyLearning(gameState); + + // Leave the finished game in place briefly so spectators can see the result. + if (gameStore.Contains(gameState.GameId)) + gameStore.ScheduleRemove(gameState.GameId, _selfPlayResultTimeout); + } + + 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; + } + + /// + /// 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); +}