@@ -54,6 +54,12 @@ jobs:
|
|||||||
name: site-publish
|
name: site-publish
|
||||||
path: publish
|
path: publish
|
||||||
|
|
||||||
|
# GitHub artifacts don't preserve the Unix executable bit, so Stockfish (the only file
|
||||||
|
# the app spawns as a subprocess) arrives non-executable. Restore 755 here; rsync -a then
|
||||||
|
# carries it to the server, where the service user can run it regardless of file owner.
|
||||||
|
- name: Restore Stockfish executable bit
|
||||||
|
run: chmod 755 publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt
|
||||||
|
|
||||||
- name: Prepare SSH
|
- name: Prepare SSH
|
||||||
run: |
|
run: |
|
||||||
install -m 700 -d ~/.ssh
|
install -m 700 -d ~/.ssh
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
using JoshHeaps.Net.Hubs;
|
using JoshHeaps.Net.Hubs;
|
||||||
using JoshHeaps.Net.Models;
|
using JoshHeaps.Net.Models;
|
||||||
using JoshHeaps.Net.Services.Implementations;
|
using JoshHeaps.Net.Services.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,14 @@ public class ChessController(
|
|||||||
IChessEngineFactory engineFactory,
|
IChessEngineFactory engineFactory,
|
||||||
IComputerMoveOrchestrator orchestrator,
|
IComputerMoveOrchestrator orchestrator,
|
||||||
ILearnedWeightsStore weightsStore,
|
ILearnedWeightsStore weightsStore,
|
||||||
|
IGameStore gameStore,
|
||||||
|
ISelfPlayCoordinator selfPlay,
|
||||||
|
AutoTrainingSettings autoTraining,
|
||||||
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 +32,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 +67,7 @@ public class ChessController(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -102,31 +91,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
|
||||||
@@ -136,6 +107,25 @@ public class ChessController(
|
|||||||
_ => ChessEngineKind.Custom
|
_ => ChessEngineKind.Custom
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current number of background auto-training games (and the allowed maximum). Auto-training
|
||||||
|
/// itself runs only outside Development; this reflects the target the service is keeping.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("autotrain")]
|
||||||
|
public ActionResult GetAutoTrain() =>
|
||||||
|
Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set how many auto-training games run concurrently (clamped to 0..max; 0 pauses training).
|
||||||
|
/// Takes effect live — the background service tops up or drains toward the new count.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("autotrain")]
|
||||||
|
public ActionResult SetAutoTrain([FromQuery] int count)
|
||||||
|
{
|
||||||
|
autoTraining.GameCount = count; // clamped inside the setter
|
||||||
|
return Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Joins the "pool" of chess players.
|
/// Joins the "pool" of chess players.
|
||||||
/// Test code expects to receive a GUID for the player
|
/// Test code expects to receive a GUID for the player
|
||||||
@@ -145,12 +135,12 @@ public class ChessController(
|
|||||||
public ActionResult JoinGame()
|
public ActionResult JoinGame()
|
||||||
{
|
{
|
||||||
Console.WriteLine("joining game");
|
Console.WriteLine("joining game");
|
||||||
GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen);
|
GameState? gameState = gameStore.All.FirstOrDefault(g => g.IsOpen);
|
||||||
|
|
||||||
if (gameState == null)
|
if (gameState == null)
|
||||||
{
|
{
|
||||||
gameState = chessService.CreateNewGame();
|
gameState = chessService.CreateNewGame();
|
||||||
_games[gameState.GameId] = gameState;
|
gameStore.Add(gameState);
|
||||||
}
|
}
|
||||||
|
|
||||||
Guid playerId = Guid.NewGuid();
|
Guid playerId = Guid.NewGuid();
|
||||||
@@ -168,7 +158,7 @@ public class ChessController(
|
|||||||
isWhite = false;
|
isWhite = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -184,7 +174,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 +220,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 +233,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 +260,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 +291,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 +309,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 +320,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 +332,7 @@ public class ChessController(
|
|||||||
[HttpGet("{gameId}/legalMoves/{pieceId}")]
|
[HttpGet("{gameId}/legalMoves/{pieceId}")]
|
||||||
public ActionResult GetLegalMoves(Guid gameId, string pieceId)
|
public ActionResult GetLegalMoves(Guid gameId, string pieceId)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(gameId, out var gameState))
|
if (!gameStore.TryGet(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
|
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
|
||||||
@@ -356,7 +346,7 @@ public class ChessController(
|
|||||||
[HttpGet("{gameId}/legalMoves")]
|
[HttpGet("{gameId}/legalMoves")]
|
||||||
public ActionResult GetAllLegalMoves(Guid gameId)
|
public ActionResult GetAllLegalMoves(Guid gameId)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(gameId, out var gameState))
|
if (!gameStore.TryGet(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
var allMoves = chessService.GetAllLegalMoves(gameState)
|
var allMoves = chessService.GetAllLegalMoves(gameState)
|
||||||
@@ -368,181 +358,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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,11 @@
|
|||||||
</select>
|
</select>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
|
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
|
||||||
|
<fieldset class="enginePicker">
|
||||||
|
<legend>Auto-train games</legend>
|
||||||
|
<input id="autoTrainCount" type="number" min="0" max="16" step="1" />
|
||||||
|
<button id="applyAutoTrain" onclick="Spectate.setAutoTrainCount()">Apply</button>
|
||||||
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<a id="backToPlay" href="/chess">← Play a game</a>
|
<a id="backToPlay" href="/chess">← Play a game</a>
|
||||||
<a id="viewWeights" href="/weights">View learned weights →</a>
|
<a id="viewWeights" href="/weights">View learned weights →</a>
|
||||||
|
|||||||
@@ -26,10 +26,20 @@ 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>();
|
||||||
|
builder.Services.AddSingleton<AutoTrainingSettings>();
|
||||||
|
|
||||||
if (!builder.Environment.IsDevelopment())
|
if (!builder.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
builder.Services.AddHostedService<AutoIpUpdateService>();
|
builder.Services.AddHostedService<AutoIpUpdateService>();
|
||||||
|
|
||||||
|
// Continuously train the learned engine against Stockfish in the background. Toggle off
|
||||||
|
// via ChessEngine:AutoTrain (env ChessEngine__AutoTrain=false) without a redeploy.
|
||||||
|
if (configuration.GetValue($"{ChessEngineOptions.SectionName}:{nameof(ChessEngineOptions.AutoTrain)}", true))
|
||||||
|
builder.Services.AddHostedService<AutoTrainingService>();
|
||||||
|
}
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Continuously trains the learned engine in the background by keeping a configurable number of
|
||||||
|
/// self-play games running — the learned engine (skill 6) against Stockfish (skill 20), alternating
|
||||||
|
/// which color Stockfish takes so the model trains on both. The target count is read live from
|
||||||
|
/// <see cref="AutoTrainingSettings"/> (adjustable from the website): when a game finishes another
|
||||||
|
/// starts to refill the pool, raising the count starts more, and lowering it lets the surplus drain
|
||||||
|
/// as games finish (0 pauses training). Registered only outside Development and gated by the
|
||||||
|
/// ChessEngine:AutoTrain config flag.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AutoTrainingService(
|
||||||
|
ISelfPlayCoordinator coordinator,
|
||||||
|
AutoTrainingSettings settings,
|
||||||
|
ILogger<AutoTrainingService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private const int LearnedSkill = 6;
|
||||||
|
private const int StockfishSkill = 20;
|
||||||
|
private static readonly TimeSpan _restartBackoff = TimeSpan.FromSeconds(5);
|
||||||
|
private static readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var running = new List<Task>();
|
||||||
|
int started = 0;
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
running.RemoveAll(t => t.IsCompleted);
|
||||||
|
|
||||||
|
int desired = settings.GameCount;
|
||||||
|
bool startFailed = false;
|
||||||
|
|
||||||
|
while (running.Count < desired && !stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Alternate Stockfish's color so the learned engine trains as both white and black.
|
||||||
|
var config = started++ % 2 == 0
|
||||||
|
? new SelfPlayConfig(ChessEngineKind.CustomLearned, LearnedSkill, ChessEngineKind.Stockfish, StockfishSkill)
|
||||||
|
: new SelfPlayConfig(ChessEngineKind.Stockfish, StockfishSkill, ChessEngineKind.CustomLearned, LearnedSkill);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (_, completion) = coordinator.StartGame(config, stoppingToken);
|
||||||
|
running.Add(completion);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Most likely an engine failing to start (e.g. Stockfish). Back off so a
|
||||||
|
// persistent failure doesn't spin a tight loop, then try again.
|
||||||
|
logger.LogError(ex, "Failed to start an auto-training game; retrying after backoff.");
|
||||||
|
startFailed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (startFailed)
|
||||||
|
await Task.Delay(_restartBackoff, stoppingToken);
|
||||||
|
else if (running.Count > 0)
|
||||||
|
// Wake when any game finishes (to refill) or after a short poll (to pick up a
|
||||||
|
// count increase promptly).
|
||||||
|
await Task.WhenAny(Task.WhenAny(running), Task.Delay(_pollInterval, stoppingToken));
|
||||||
|
else
|
||||||
|
// Pool is empty (count is 0) — just poll for the count to change.
|
||||||
|
await Task.Delay(_pollInterval, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runtime-adjustable auto-training settings. Singleton so the value set from the website (via the
|
||||||
|
/// chess controller) is seen live by the background <see cref="AutoTrainingService"/>. Seeded from
|
||||||
|
/// <see cref="ChessEngineOptions.AutoTrainGameCount"/> and clamped to a sane range.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AutoTrainingSettings
|
||||||
|
{
|
||||||
|
/// <summary>Upper bound on concurrent auto-training games (each spawns a Stockfish + a learned engine).</summary>
|
||||||
|
public const int MaxGames = 16;
|
||||||
|
|
||||||
|
private int _gameCount;
|
||||||
|
|
||||||
|
public AutoTrainingSettings(IOptions<ChessEngineOptions> options)
|
||||||
|
=> _gameCount = Clamp(options.Value.AutoTrainGameCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of auto-training games to keep running concurrently. 0 pauses auto-training.
|
||||||
|
/// Reads/writes are atomic; the background service reads this every cycle.
|
||||||
|
/// </summary>
|
||||||
|
public int GameCount
|
||||||
|
{
|
||||||
|
get => Volatile.Read(ref _gameCount);
|
||||||
|
set => Volatile.Write(ref _gameCount, Clamp(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Clamp(int n) => Math.Clamp(n, 0, MaxGames);
|
||||||
|
}
|
||||||
@@ -29,6 +29,20 @@ 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>
|
||||||
|
/// How many auto-training games run concurrently (when <see cref="AutoTrain"/> is on). This is
|
||||||
|
/// the starting value; it can be changed at runtime from the website. Override the default via
|
||||||
|
/// the <c>ChessEngine__AutoTrainGameCount</c> environment variable.
|
||||||
|
/// </summary>
|
||||||
|
public int AutoTrainGameCount { get; set; } = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
|
/// <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,257 @@
|
|||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs CPU-vs-CPU games: builds the game and engines, plays a randomized opening (for training
|
||||||
|
/// variety), drives the move loop to completion, then trains the learned engine from the result.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SelfPlayCoordinator(
|
||||||
|
IChessService chessService,
|
||||||
|
IChessEngineFactory engineFactory,
|
||||||
|
IComputerMoveOrchestrator orchestrator,
|
||||||
|
ILearnedWeightsStore weightsStore,
|
||||||
|
IGameStore gameStore) : ISelfPlayCoordinator
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||||
|
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
|
||||||
|
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
// Abandon a game that goes this long without a move being played — i.e. an engine (usually
|
||||||
|
// Stockfish) that crashed or froze. The game is killed with no result recorded; if it was an
|
||||||
|
// auto-training game the trainer schedules a replacement once this one's task completes.
|
||||||
|
private static readonly TimeSpan _idleTimeout = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
// Plies of random legal moves at the start of a training game, so self-play and
|
||||||
|
// engine-vs-engine games explore different lines instead of replaying one game.
|
||||||
|
private const int _openingRandomPlies = 4;
|
||||||
|
|
||||||
|
public (Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var whiteComputer = engineFactory.Create(config.WhiteSkill, config.WhiteKind);
|
||||||
|
IChessEngine blackComputer;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
blackComputer = engineFactory.Create(config.BlackSkill, config.BlackKind);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Don't leak the first engine if the second fails to start (e.g. Stockfish process).
|
||||||
|
whiteComputer.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var gameState = chessService.CreateNewGame();
|
||||||
|
gameState.IsVsComputer = true;
|
||||||
|
gameState.IsComputerVsComputer = true;
|
||||||
|
gameState.WhiteJoined = true;
|
||||||
|
gameState.BlackJoined = true;
|
||||||
|
gameState.WhitePlayerId = Guid.NewGuid();
|
||||||
|
gameState.BlackPlayerId = Guid.NewGuid();
|
||||||
|
gameState.WhiteEngineKind = config.WhiteKind;
|
||||||
|
gameState.BlackEngineKind = config.BlackKind;
|
||||||
|
gameState.WhiteComputer = whiteComputer;
|
||||||
|
gameState.BlackComputer = blackComputer;
|
||||||
|
|
||||||
|
// When the learned engine is playing, attach a trainer so the outcome can train it.
|
||||||
|
if (config.WhiteKind == ChessEngineKind.CustomLearned || config.BlackKind == ChessEngineKind.CustomLearned)
|
||||||
|
gameState.Trainer = weightsStore.CreateTrainer();
|
||||||
|
|
||||||
|
gameStore.Add(gameState);
|
||||||
|
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
|
||||||
|
|
||||||
|
var completion = Task.Run(() => RunAsync(gameState, cancellationToken));
|
||||||
|
return (gameState.GameId, completion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives the game to completion, then trains from it. Never throws — a failure (or a frozen
|
||||||
|
/// engine) just ends the game and the trainer is always freed, so callers can await or ignore.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RunAsync(GameState gameState, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
bool aborted = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Give spectators a moment to join the SignalR group before the first move.
|
||||||
|
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||||
|
|
||||||
|
// Training games open with random moves so they don't replay the same line.
|
||||||
|
if (gameState.Trainer != nint.Zero)
|
||||||
|
for (int i = 0; i < _openingRandomPlies && IsLive(gameState, cancellationToken); i++)
|
||||||
|
{
|
||||||
|
await orchestrator.PlayRandomMoveAsync(gameState);
|
||||||
|
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastMoveCount = gameState.MoveHistory.Count;
|
||||||
|
var lastProgress = DateTime.UtcNow;
|
||||||
|
|
||||||
|
while (IsLive(gameState, cancellationToken))
|
||||||
|
{
|
||||||
|
await PlayMoveWithTimeoutAsync(gameState, cancellationToken);
|
||||||
|
|
||||||
|
// Watchdog: abandon the game if it stops making moves (a crashed or frozen engine
|
||||||
|
// can leave PlayAsync returning without progressing). Reset the clock on a real
|
||||||
|
// move; otherwise bail once nothing has happened for the idle timeout.
|
||||||
|
if (gameState.MoveHistory.Count != lastMoveCount)
|
||||||
|
{
|
||||||
|
lastMoveCount = gameState.MoveHistory.Count;
|
||||||
|
lastProgress = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
else if (DateTime.UtcNow - lastProgress > _idleTimeout)
|
||||||
|
throw new TimeoutException("no move played within the idle timeout");
|
||||||
|
|
||||||
|
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { aborted = true; /* service shutting down */ }
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
aborted = true;
|
||||||
|
Console.WriteLine($"Self-play game {gameState.GameId} abandoned: no move for over " +
|
||||||
|
$"{_idleTimeout.TotalSeconds:n0}s (likely a crashed or frozen engine).");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
aborted = true;
|
||||||
|
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A clean finish trains the learned engine; an abandoned game (cancelled, crashed, or
|
||||||
|
// idle past the timeout) records nothing and just frees its trainer.
|
||||||
|
if (aborted)
|
||||||
|
DiscardTraining(gameState);
|
||||||
|
else
|
||||||
|
ApplyLearning(gameState);
|
||||||
|
|
||||||
|
// A clean finish lingers briefly so spectators see the result; an abandoned game is torn
|
||||||
|
// down immediately so its engines (and any crashed/frozen Stockfish process) are released.
|
||||||
|
if (gameStore.Contains(gameState.GameId))
|
||||||
|
gameStore.ScheduleRemove(gameState.GameId, aborted ? TimeSpan.Zero : _selfPlayResultTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plays one engine move, abandoning it if it exceeds <see cref="_idleTimeout"/> (throwing
|
||||||
|
/// <see cref="TimeoutException"/>) so a single hung move can't block the loop forever. The
|
||||||
|
/// abandoned move's eventual fault — it errors once the game's engines are disposed — is
|
||||||
|
/// observed so it isn't an unobserved task exception.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PlayMoveWithTimeoutAsync(GameState gameState, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var play = orchestrator.PlayAsync(gameState);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await play.WaitAsync(_idleTimeout, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
_ = play.ContinueWith(static t => { _ = t.Exception; }, TaskScheduler.Default);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsLive(GameState gameState, CancellationToken cancellationToken) =>
|
||||||
|
!cancellationToken.IsCancellationRequested
|
||||||
|
&& gameStore.Contains(gameState.GameId)
|
||||||
|
&& !IsGameOver(gameState);
|
||||||
|
|
||||||
|
private static bool IsGameOver(GameState gameState) =>
|
||||||
|
gameState.IsCheckmate || gameState.IsStalemate || gameState.IsThreefoldRepetition || gameState.IsForfeited;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feeds a finished training game's result into the learned weights, then frees the trainer.
|
||||||
|
/// A checkmate is a full-strength result; a material-imbalance draw is a half-strength win
|
||||||
|
/// for the lower-material side (holding a draw while down material is a success; only drawing
|
||||||
|
/// while up is a failure). A balanced draw, forfeit, or unfinished game teaches nothing.
|
||||||
|
/// </summary>
|
||||||
|
private void ApplyLearning(GameState gameState)
|
||||||
|
{
|
||||||
|
if (gameState.Trainer == nint.Zero)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (TryDetermineOutcome(gameState, out var winner, out var weight))
|
||||||
|
weightsStore.ApplyResult(gameState.Trainer, winner, weight);
|
||||||
|
|
||||||
|
weightsStore.DestroyTrainer(gameState.Trainer);
|
||||||
|
gameState.Trainer = nint.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frees an abandoned game's trainer without recording any result — a cancelled, crashed, or
|
||||||
|
/// idle-timed-out game teaches the model nothing.
|
||||||
|
/// </summary>
|
||||||
|
private void DiscardTraining(GameState gameState)
|
||||||
|
{
|
||||||
|
if (gameState.Trainer == nint.Zero)
|
||||||
|
return;
|
||||||
|
|
||||||
|
weightsStore.DestroyTrainer(gameState.Trainer);
|
||||||
|
gameState.Trainer = nint.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines the trainable outcome of a finished game: the winning color and the reward
|
||||||
|
/// weight. Returns false when the game teaches nothing (balanced draw, forfeit, unfinished).
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryDetermineOutcome(GameState gameState, out PieceColor winner, out double weight)
|
||||||
|
{
|
||||||
|
winner = PieceColor.White;
|
||||||
|
weight = 1.0;
|
||||||
|
|
||||||
|
if (gameState.IsCheckmate)
|
||||||
|
{
|
||||||
|
// The side to move is the mated one, so the winner is the other color.
|
||||||
|
winner = gameState.CurrentPlayer == PieceColor.White ? PieceColor.Black : PieceColor.White;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameState.IsStalemate || gameState.IsThreefoldRepetition)
|
||||||
|
{
|
||||||
|
var (white, black) = MaterialCounts(gameState);
|
||||||
|
|
||||||
|
if (white == black)
|
||||||
|
return false; // a balanced draw carries no signal
|
||||||
|
|
||||||
|
winner = white < black ? PieceColor.White : PieceColor.Black;
|
||||||
|
weight = 0.5;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // forfeit / unfinished
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Total non-king material per side (P=1, N=B=3, R=5, Q=9), for draw adjudication.</summary>
|
||||||
|
private static (int white, int black) MaterialCounts(GameState gameState)
|
||||||
|
{
|
||||||
|
int white = 0, black = 0;
|
||||||
|
|
||||||
|
for (int row = 0; row < 8; row++)
|
||||||
|
for (int col = 0; col < 8; col++)
|
||||||
|
{
|
||||||
|
var piece = gameState.Board[row, col];
|
||||||
|
|
||||||
|
if (piece is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
int value = piece.Type switch
|
||||||
|
{
|
||||||
|
PieceType.Pawn => 1,
|
||||||
|
PieceType.Knight => 3,
|
||||||
|
PieceType.Bishop => 3,
|
||||||
|
PieceType.Rook => 5,
|
||||||
|
PieceType.Queen => 9,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
|
||||||
|
if (piece.Color == PieceColor.White)
|
||||||
|
white += value;
|
||||||
|
else
|
||||||
|
black += value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (white, black);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -23,9 +23,40 @@ const Spectate = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.refreshGames();
|
await this.refreshGames();
|
||||||
|
await this.loadAutoTrainCount();
|
||||||
setInterval(() => this.refreshGames(), 5000);
|
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() {
|
async startCpuGame() {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
whiteEngine: document.getElementById("whiteEngine").value,
|
whiteEngine: document.getElementById("whiteEngine").value,
|
||||||
|
|||||||
Reference in New Issue
Block a user