Gitea/workflows #1

Closed
jheaps wants to merge 47 commits from gitea/workflows into AddProject
8 changed files with 404 additions and 229 deletions
Showing only changes of commit aeef320cb4 - Show all commits
+23 -229
View File
@@ -4,7 +4,6 @@ using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces; using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using System.Collections.Concurrent;
namespace JoshHeaps.Net.Controllers; namespace JoshHeaps.Net.Controllers;
@@ -16,24 +15,13 @@ public class ChessController(
IChessEngineFactory engineFactory, IChessEngineFactory engineFactory,
IComputerMoveOrchestrator orchestrator, IComputerMoveOrchestrator orchestrator,
ILearnedWeightsStore weightsStore, ILearnedWeightsStore weightsStore,
IGameStore gameStore,
ISelfPlayCoordinator selfPlay,
IHubContext<ChessHub> chessHub) : ControllerBase IHubContext<ChessHub> chessHub) : ControllerBase
{ {
/// <summary>
/// Store of ongoing games.
/// </summary>
private static readonly ConcurrentDictionary<Guid, GameState> _games = [];
private static readonly ConcurrentDictionary<Guid, Task> _gameRemovalTasks = [];
private static readonly ConcurrentDictionary<Guid, CancellationTokenSource> _gameRemovalCancellationTokens = [];
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1); private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1); private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1); private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
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;
/// <summary> /// <summary>
/// Create a new chess game and store it in-memory. /// 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") public ActionResult CreateGame(int difficulty = 20, string color = "random")
{ {
var gameState = chessService.CreateNewGame(); var gameState = chessService.CreateNewGame();
_games[gameState.GameId] = gameState; gameStore.Add(gameState);
gameState.IsVsComputer = true; gameState.IsVsComputer = true;
gameState.WhiteJoined = true; gameState.WhiteJoined = true;
@@ -78,7 +66,7 @@ public class ChessController(
}); });
} }
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout); gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
return Ok(new return Ok(new
{ {
@@ -102,31 +90,13 @@ public class ChessController(
int? whiteSkill = null, int? whiteSkill = null,
int? blackSkill = null) int? blackSkill = null)
{ {
var whiteKind = ParseEngineKind(whiteEngine); var config = new SelfPlayConfig(
var blackKind = ParseEngineKind(blackEngine); ParseEngineKind(whiteEngine), whiteSkill ?? difficulty,
ParseEngineKind(blackEngine), blackSkill ?? difficulty);
var gameState = chessService.CreateNewGame(); var (gameId, _) = selfPlay.StartGame(config);
_games[gameState.GameId] = gameState;
gameState.IsVsComputer = true; return Ok(new { GameId = gameId });
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 });
} }
private static ChessEngineKind ParseEngineKind(string value) => value.ToLowerInvariant() switch private static ChessEngineKind ParseEngineKind(string value) => value.ToLowerInvariant() switch
@@ -145,12 +115,12 @@ public class ChessController(
public ActionResult JoinGame() public ActionResult JoinGame()
{ {
Console.WriteLine("joining game"); Console.WriteLine("joining game");
GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen); GameState? gameState = gameStore.All.FirstOrDefault(g => g.IsOpen);
if (gameState == null) if (gameState == null)
{ {
gameState = chessService.CreateNewGame(); gameState = chessService.CreateNewGame();
_games[gameState.GameId] = gameState; gameStore.Add(gameState);
} }
Guid playerId = Guid.NewGuid(); Guid playerId = Guid.NewGuid();
@@ -168,7 +138,7 @@ public class ChessController(
isWhite = false; isWhite = false;
} }
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout); gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
return Ok(new return Ok(new
{ {
@@ -184,7 +154,7 @@ public class ChessController(
[HttpGet("active")] [HttpGet("active")]
public ActionResult GetActiveGames() 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. // In-progress games, plus finished computer-vs-computer games still in their result window.
.Where(g => g.WhiteJoined && g.BlackJoined .Where(g => g.WhiteJoined && g.BlackJoined
&& ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer)) && ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer))
@@ -230,7 +200,7 @@ public class ChessController(
[HttpGet("{gameId}")] [HttpGet("{gameId}")]
public ActionResult GetGameState(Guid gameId) public ActionResult GetGameState(Guid gameId)
{ {
if (!_games.TryGetValue(gameId, out var gameState)) if (!gameStore.TryGet(gameId, out var gameState))
return NotFound("Game not found"); return NotFound("Game not found");
return Ok(gameState.ToDto()); return Ok(gameState.ToDto());
@@ -243,7 +213,7 @@ public class ChessController(
[HttpPost("move")] [HttpPost("move")]
public async Task<ActionResult> MakeMove([FromBody] MoveDto moveDto) public async Task<ActionResult> MakeMove([FromBody] MoveDto moveDto)
{ {
if (!_games.TryGetValue(moveDto.GameId, out var gameState)) if (!gameStore.TryGet(moveDto.GameId, out var gameState))
return NotFound("Game not found"); return NotFound("Game not found");
// Check if player is authorized to move // Check if player is authorized to move
@@ -270,11 +240,11 @@ public class ChessController(
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition; var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
if (isGameOver) if (isGameOver)
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout); gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
else if (gameState.IsVsComputer) else if (gameState.IsVsComputer)
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout); gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
else else
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout); gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
var state = gameState.ToDto(); var state = gameState.ToDto();
@@ -301,7 +271,7 @@ public class ChessController(
[HttpPost("forfeit")] [HttpPost("forfeit")]
public async Task<ActionResult> Forfeit([FromBody] ForfeitDto 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"); return NotFound("Game not found");
if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited) if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited)
@@ -319,7 +289,7 @@ public class ChessController(
await chessHub.Clients.Group(gameState.GameId.ToString()) await chessHub.Clients.Group(gameState.GameId.ToString())
.SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit"); .SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit");
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout); gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
return Ok(); return Ok();
} }
@@ -330,7 +300,7 @@ public class ChessController(
[HttpGet("{gameId}/pgn")] [HttpGet("{gameId}/pgn")]
public ActionResult GetPgn(Guid gameId) 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 NotFound("Game not found");
return Content(gameState.ToPgn(), "application/x-chess-pgn"); return Content(gameState.ToPgn(), "application/x-chess-pgn");
@@ -342,7 +312,7 @@ public class ChessController(
[HttpGet("{gameId}/legalMoves/{pieceId}")] [HttpGet("{gameId}/legalMoves/{pieceId}")]
public ActionResult GetLegalMoves(Guid gameId, string pieceId) public ActionResult GetLegalMoves(Guid gameId, string pieceId)
{ {
if (!_games.TryGetValue(gameId, out var gameState)) if (!gameStore.TryGet(gameId, out var gameState))
return NotFound("Game not found"); return NotFound("Game not found");
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId); var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
@@ -356,7 +326,7 @@ public class ChessController(
[HttpGet("{gameId}/legalMoves")] [HttpGet("{gameId}/legalMoves")]
public ActionResult GetAllLegalMoves(Guid gameId) public ActionResult GetAllLegalMoves(Guid gameId)
{ {
if (!_games.TryGetValue(gameId, out var gameState)) if (!gameStore.TryGet(gameId, out var gameState))
return NotFound("Game not found"); return NotFound("Game not found");
var allMoves = chessService.GetAllLegalMoves(gameState) var allMoves = chessService.GetAllLegalMoves(gameState)
@@ -369,180 +339,4 @@ public class ChessController(
return Ok(allMoves); return Ok(allMoves);
} }
/// <summary>
/// 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.
/// </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));
// 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;
/// <summary>
/// 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).
/// </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>
/// 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);
}
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();
}
});
}
} }
+9
View File
@@ -26,10 +26,19 @@ builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEng
builder.Services.AddSingleton<ILearnedWeightsStore, LearnedWeightsStore>(); builder.Services.AddSingleton<ILearnedWeightsStore, LearnedWeightsStore>();
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>(); builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>(); builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
builder.Services.AddSingleton<IGameStore, GameStore>();
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
if (!builder.Environment.IsDevelopment()) if (!builder.Environment.IsDevelopment())
{
builder.Services.AddHostedService<AutoIpUpdateService>(); builder.Services.AddHostedService<AutoIpUpdateService>();
// Continuously train the learned engine against Stockfish in the background. Toggle off
// via ChessEngine:AutoTrain (env ChessEngine__AutoTrain=false) without a redeploy.
if (configuration.GetValue($"{ChessEngineOptions.SectionName}:{nameof(ChessEngineOptions.AutoTrain)}", true))
builder.Services.AddHostedService<AutoTrainingService>();
}
var app = builder.Build(); var app = builder.Build();
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
@@ -0,0 +1,55 @@
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// 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.
/// </summary>
public sealed class AutoTrainingService(
ISelfPlayCoordinator coordinator,
ILogger<AutoTrainingService> 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; }
}
}
}
}
@@ -29,6 +29,13 @@ public sealed class ChessEngineOptions
/// clashes. Override via the <c>ChessEngine__WeightsPath</c> environment variable. /// clashes. Override via the <c>ChessEngine__WeightsPath</c> environment variable.
/// </summary> /// </summary>
public string? WeightsPath { get; set; } 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>Creates the configured <see cref="IChessEngine"/> per game.</summary> /// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
@@ -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,189 @@
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);
// 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 just ends
/// the game (and the trainer is always freed), so callers can safely await or ignore it.
/// </summary>
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;
/// <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>
/// 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);
}
}
@@ -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,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);
}