diff --git a/.gitignore b/.gitignore index e8379fe..6a4cfa1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ ## ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +# Learned chess engine weights (runtime training output, not source) +chess-data/ + # User-specific files *.rsuser *.suo diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index df9190f..350d8f2 100644 --- a/JoshHeaps.Net/Controllers/ChessController.cs +++ b/JoshHeaps.Net/Controllers/ChessController.cs @@ -15,6 +15,7 @@ public class ChessController( IBackgroundTaskQueue queue, IChessEngineFactory engineFactory, IComputerMoveOrchestrator orchestrator, + ILearnedWeightsStore weightsStore, IHubContext chessHub) : ControllerBase { /// @@ -30,12 +31,16 @@ public class ChessController( 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. /// [HttpGet("new")] [HttpGet("new/{difficulty}")] - public ActionResult CreateGame(int difficulty = 20) + public ActionResult CreateGame(int difficulty = 20, string color = "random") { var gameState = chessService.CreateNewGame(); _games[gameState.GameId] = gameState; @@ -45,24 +50,31 @@ public class ChessController( gameState.BlackJoined = true; Guid playerId = Guid.NewGuid(); Guid computerId = Guid.NewGuid(); - var isWhite = Random.Shared.Next(2) == 0; + var isWhite = color.ToLowerInvariant() switch + { + "white" => true, + "black" => false, + _ => Random.Shared.Next(2) == 0, + }; - gameState.Computer = engineFactory.Create(difficulty); + var computer = engineFactory.Create(difficulty); if (isWhite) { gameState.WhitePlayerId = playerId; gameState.BlackPlayerId = computerId; + gameState.BlackComputer = computer; } else { gameState.WhitePlayerId = computerId; gameState.BlackPlayerId = playerId; + gameState.WhiteComputer = computer; queue.Queue(async () => { // Give user's browser time to connect to signalR and such. await Task.Delay(TimeSpan.FromSeconds(1)); - await orchestrator.PlayAsync(gameState, gameState.Computer!); + await orchestrator.PlayAsync(gameState); }); } @@ -77,13 +89,22 @@ public class ChessController( } /// - /// Create a game the computer plays against itself and auto-play it move by move, - /// broadcasting each move so it can be watched on the spectator page. + /// Create a computer-vs-computer game and auto-play it move by move, broadcasting each + /// move so it can be watched on the spectator page. Each side's engine and skill can be + /// chosen independently; when the learned engine plays, the game also trains it. /// [HttpGet("watch/cpu")] [HttpGet("watch/cpu/{difficulty}")] - public ActionResult CreateSelfPlayGame(int difficulty = 4) + public ActionResult CreateSelfPlayGame( + int difficulty = 4, + string whiteEngine = "custom", + string blackEngine = "custom", + int? whiteSkill = null, + int? blackSkill = null) { + var whiteKind = ParseEngineKind(whiteEngine); + var blackKind = ParseEngineKind(blackEngine); + var gameState = chessService.CreateNewGame(); _games[gameState.GameId] = gameState; @@ -93,7 +114,14 @@ public class ChessController( gameState.BlackJoined = true; gameState.WhitePlayerId = Guid.NewGuid(); gameState.BlackPlayerId = Guid.NewGuid(); - gameState.Computer = engineFactory.Create(difficulty); + 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); @@ -101,6 +129,13 @@ public class ChessController( return Ok(new { gameState.GameId }); } + private static ChessEngineKind ParseEngineKind(string value) => value.ToLowerInvariant() switch + { + "stockfish" => ChessEngineKind.Stockfish, + "customlearned" or "learned" => ChessEngineKind.CustomLearned, + _ => ChessEngineKind.Custom + }; + /// /// Joins the "pool" of chess players. /// Test code expects to receive a GUID for the player @@ -158,6 +193,8 @@ public class ChessController( g.GameId, g.IsVsComputer, g.IsComputerVsComputer, + WhiteEngine = g.WhiteEngineKind.ToString(), + BlackEngine = g.BlackEngineKind.ToString(), CurrentPlayer = g.CurrentPlayer.ToString(), MoveCount = g.MoveHistory.Count, g.IsCheck @@ -167,6 +204,26 @@ public class ChessController( return Ok(activeGames); } + /// + /// The learned engine's piece-square bonus table, for the weights-visualization page: + /// one 64-entry array per piece type (Pawn..King), white-relative (A1=0 .. H8=63). + /// + [HttpGet("weights")] + public ActionResult GetLearnedWeights() + { + var names = new[] { "Pawn", "Knight", "Bishop", "Rook", "Queen", "King" }; + var featureNames = new[] { "Mobility N", "Mobility B", "Mobility R", "Mobility Q", "Passed", "Isolated", "Doubled", "King safety" }; + + var snapshot = weightsStore.Snapshot(); + + return Ok(new + { + mg = snapshot.Mg.Select((squares, i) => new { name = names[i], squares }), + eg = snapshot.Eg.Select((squares, i) => new { name = names[i], squares }), + features = snapshot.Features.Select((value, i) => new { name = featureNames[i], value }) + }); + } + /// /// Get the state of an existing game by ID. /// @@ -227,8 +284,12 @@ public class ChessController( await chessHub.Clients.Group(gameState.GameId.ToString()) .SendAsync("ReceiveMoveUpdate", gameState.GameId.ToString(), moveDto, result, state); - if (!isGameOver && gameState.IsVsComputer && gameState.Computer is not null) - queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!)); + var sideToMoveEngine = gameState.CurrentPlayer == PieceColor.White + ? gameState.WhiteComputer + : gameState.BlackComputer; + + if (!isGameOver && gameState.IsVsComputer && sideToMoveEngine is not null) + queue.Queue(() => orchestrator.PlayAsync(gameState)); return Ok(new { result, state }); } @@ -309,8 +370,9 @@ public class ChessController( } /// - /// Drives a computer-vs-computer game: keeps asking the engine for the side-to-move's - /// move (which applies and broadcasts it) until the game ends or is removed. + /// 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) { @@ -319,15 +381,19 @@ public class ChessController( // Give spectators a moment to join the SignalR group before the first move. await Task.Delay(TimeSpan.FromSeconds(1)); - while (_games.ContainsKey(gameState.GameId) - && !gameState.IsCheckmate - && !gameState.IsStalemate - && !gameState.IsThreefoldRepetition - && !gameState.IsForfeited) + // 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, gameState.Computer!); + await orchestrator.PlayAsync(gameState); } catch (Exception ex) { @@ -338,12 +404,100 @@ public class ChessController( 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)) @@ -361,8 +515,21 @@ public class ChessController( { await Task.Delay(delay, cts.Token); - if (_games.TryGetValue(id, out var game) && game.Computer is not null) - await game.Computer.DisposeAsync(); + 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 _); } diff --git a/JoshHeaps.Net/Models/GameState.cs b/JoshHeaps.Net/Models/GameState.cs index c3c86da..7ad3e13 100644 --- a/JoshHeaps.Net/Models/GameState.cs +++ b/JoshHeaps.Net/Models/GameState.cs @@ -1,4 +1,5 @@ -using JoshHeaps.Net.Services.Interfaces; +using JoshHeaps.Net.Services.Implementations; +using JoshHeaps.Net.Services.Interfaces; namespace JoshHeaps.Net.Models; @@ -59,7 +60,20 @@ public class GameState public bool IsVsComputer { get; set; } = false; public bool IsComputerVsComputer { get; set; } = false; - public IChessEngine? Computer { get; set; } + // The engine playing each side (null for a human). In a human-vs-computer game only the + // computer's side is set; the orchestrator picks the engine for whoever is to move. + public IChessEngine? WhiteComputer { get; set; } + public IChessEngine? BlackComputer { get; set; } + + // Which engine implementation each side uses — lets game-over handling know which side(s) + // are the learning engine, and lets spectators see who is playing. + public ChessEngineKind WhiteEngineKind { get; set; } + public ChessEngineKind BlackEngineKind { get; set; } + + // Native per-game training accumulator (nint.Zero when this game isn't training the + // learned engine). The engine records each played position into it and applies the + // result on game over. + public nint Trainer { get; set; } // optional: convenience public bool IsOpen => !WhiteJoined || !BlackJoined; diff --git a/JoshHeaps.Net/Models/LearnedWeightsSnapshot.cs b/JoshHeaps.Net/Models/LearnedWeightsSnapshot.cs new file mode 100644 index 0000000..a234c64 --- /dev/null +++ b/JoshHeaps.Net/Models/LearnedWeightsSnapshot.cs @@ -0,0 +1,9 @@ +namespace JoshHeaps.Net.Models; + +/// +/// A copy of the learned engine's weights for display: midgame and endgame piece-square +/// tables (one 64-entry array per piece, in canonical Pawn..King order, white-relative +/// A1=0..H8=63) plus the feature weights (mobility N/B/R/Q, passed, isolated, doubled, +/// king safety). +/// +public sealed record LearnedWeightsSnapshot(int[][] Mg, int[][] Eg, int[] Features); diff --git a/JoshHeaps.Net/Pages/Chess.cshtml b/JoshHeaps.Net/Pages/Chess.cshtml index d87778c..817ae1a 100644 --- a/JoshHeaps.Net/Pages/Chess.cshtml +++ b/JoshHeaps.Net/Pages/Chess.cshtml @@ -89,6 +89,15 @@ + + @section Scripts { diff --git a/JoshHeaps.Net/Pages/Watch.cshtml b/JoshHeaps.Net/Pages/Watch.cshtml index 80635e6..3e04add 100644 --- a/JoshHeaps.Net/Pages/Watch.cshtml +++ b/JoshHeaps.Net/Pages/Watch.cshtml @@ -9,16 +9,38 @@

Live Chess

Loading games…

- - +
+ White + + +
+
+ Black + + +
← Play a game + View learned weights →
diff --git a/JoshHeaps.Net/Pages/Weights.cshtml b/JoshHeaps.Net/Pages/Weights.cshtml new file mode 100644 index 0000000..c784c51 --- /dev/null +++ b/JoshHeaps.Net/Pages/Weights.cshtml @@ -0,0 +1,44 @@ +@page +@model JoshHeaps.Net.Pages.WeightsModel +@{ + Layout = "_Layout"; + ViewData["Title"] = "Learned Weights"; +} + +
+

Learned Piece-Square Weights

+

Where the learned engine thinks each piece belongs.

+
+
+ low + + high +
+ +
+ ← Watch / train +
+ +
+

Feature weights

+

Learned value of each contextual feature (per normalized unit). Mobility is per piece type; passed pawns are endgame-weighted, king safety midgame-weighted.

+
+
+ +
+

Midgame tables

+
+
+ +
+

Endgame tables

+
+
+ +@section Scripts { + +} + +@section Styles { + +} diff --git a/JoshHeaps.Net/Pages/Weights.cshtml.cs b/JoshHeaps.Net/Pages/Weights.cshtml.cs new file mode 100644 index 0000000..baed090 --- /dev/null +++ b/JoshHeaps.Net/Pages/Weights.cshtml.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace JoshHeaps.Net.Pages +{ + public class WeightsModel : PageModel + { + public void OnGet() + { + } + } +} diff --git a/JoshHeaps.Net/Program.cs b/JoshHeaps.Net/Program.cs index 4c0e0c8..0657f4e 100644 --- a/JoshHeaps.Net/Program.cs +++ b/JoshHeaps.Net/Program.cs @@ -23,6 +23,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.Configure(configuration.GetSection(ChessEngineOptions.SectionName)); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JoshHeaps.Net/Resources/chess_engine.dll b/JoshHeaps.Net/Resources/chess_engine.dll index f7f7bfd..992d57e 100644 Binary files a/JoshHeaps.Net/Resources/chess_engine.dll and b/JoshHeaps.Net/Resources/chess_engine.dll differ diff --git a/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs b/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs index 34b7c55..2ccd590 100644 --- a/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs +++ b/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs @@ -7,7 +7,10 @@ namespace JoshHeaps.Net.Services.Implementations; public enum ChessEngineKind { Stockfish, - Custom + Custom, + + /// The custom engine with the reinforcement-learned piece-square evaluation. + CustomLearned } /// Configuration selecting which to use. @@ -19,14 +22,19 @@ public sealed class ChessEngineOptions } /// Creates the configured per game. -public sealed class ChessEngineFactory(IOptions options) : IChessEngineFactory +public sealed class ChessEngineFactory( + IOptions options, + ILearnedWeightsStore weightsStore) : IChessEngineFactory { private readonly ChessEngineKind _kind = options.Value.Engine; - public IChessEngine Create(int skill) => _kind switch + public IChessEngine Create(int skill) => Create(skill, _kind); + + public IChessEngine Create(int skill, ChessEngineKind kind) => kind switch { ChessEngineKind.Custom => new CustomChessEngine(skill), + ChessEngineKind.CustomLearned => new CustomChessEngine(skill, EngineVariant.Learned, weightsStore.WeightsFilePath), ChessEngineKind.Stockfish => new Stockfish(skill), - _ => throw new InvalidOperationException($"Unknown chess engine '{_kind}'.") + _ => throw new InvalidOperationException($"Unknown chess engine '{kind}'.") }; } diff --git a/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs b/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs index 244b6f6..540979c 100644 --- a/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs +++ b/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs @@ -11,23 +11,60 @@ namespace JoshHeaps.Net.Services.Implementations; ///
public sealed class ComputerMoveOrchestrator( IHubContext chessHub, - IChessService chessService) : IComputerMoveOrchestrator + IChessService chessService, + ILearnedWeightsStore weightsStore) : IComputerMoveOrchestrator { - public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine) + public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state) { + var engine = (state.CurrentPlayer == PieceColor.White ? state.WhiteComputer : state.BlackComputer) + ?? throw new InvalidOperationException($"No engine is set for {state.CurrentPlayer} in game {state.GameId}."); + var uci = await engine.GetBestMoveAsync(state.ToFen(), state.RepetitionHistory()); + var move = uci.ToMoveDto(state, CurrentPlayerId(state)); - var move = uci.ToMoveDto( - state, - state.CurrentPlayer == PieceColor.White - ? state.WhitePlayerId - : state.BlackPlayerId); + return await ApplyAndBroadcastAsync(state, move); + } + public async Task PlayRandomMoveAsync(GameState state) + { + var options = chessService.GetAllLegalMoves(state); + + // No legal moves means the game is already over; let the caller's loop detect it. + if (options.Count == 0) + return; + + var (piece, moves) = options[Random.Shared.Next(options.Count)]; + var target = moves[Random.Shared.Next(moves.Count)]; + + var move = new MoveDto + { + GameId = state.GameId, + PlayerId = CurrentPlayerId(state), + PieceId = piece.Id, + SourceRow = piece.Position.Row, + SourceCol = piece.Position.Col, + TargetRow = target.Row, + TargetCol = target.Col, + PromotionChoice = null // a pawn cannot reach the last rank within the opening plies + }; + + await ApplyAndBroadcastAsync(state, move); + } + + private async Task<(MoveDto move, MoveResultDto result)> ApplyAndBroadcastAsync(GameState state, MoveDto move) + { var result = chessService.MakeMove(state, move); + // Record the played position for training (no-op for non-training games). + if (state.Trainer != nint.Zero) + weightsStore.Record(state.Trainer, state.ToFen()); + await chessHub.Clients.Group(state.GameId.ToString()) .SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result, state.ToDto()); return (move, result); } + + private static Guid CurrentPlayerId(GameState state) => + state.CurrentPlayer == PieceColor.White ? state.WhitePlayerId : state.BlackPlayerId; } diff --git a/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs b/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs index 5675a69..d7bb8fd 100644 --- a/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs +++ b/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs @@ -5,6 +5,16 @@ using System.Runtime.InteropServices; namespace JoshHeaps.Net.Services.Implementations; +/// Which evaluation the native engine uses. +public enum EngineVariant +{ + /// The hand-crafted evaluation. + Classic, + + /// Material plus a learned per-square bonus table loaded from a weights file. + Learned +} + /// /// Middleman wrapper over the native custom chess engine (chess_engine.dll / libchess_engine.so). /// Shares with so the two are swappable. @@ -16,11 +26,17 @@ public sealed partial class CustomChessEngine : IChessEngine public int Skill { get; } - public CustomChessEngine(int skill = 20) + public CustomChessEngine(int skill = 20, EngineVariant variant = EngineVariant.Classic, string? weightsPath = null) { Skill = skill; - var handle = NativeMethods.engine_create($"skill={skill}"); + // weights= must come last: the native side reads the path as the rest of the + // string, which lets it contain ';' and spaces. + var options = variant == EngineVariant.Learned + ? $"skill={skill};variant=learned;weights={weightsPath}" + : $"skill={skill}"; + + var handle = NativeMethods.engine_create(options); if (handle == IntPtr.Zero) throw new InvalidOperationException("Native chess engine failed to initialize (engine_create returned null)."); @@ -79,8 +95,9 @@ public sealed partial class CustomChessEngine : IChessEngine /// /// P/Invoke surface for chess_engine.(dll|so). The resolver maps the logical name /// "chess_engine" to the platform binary in the Resources folder (mirrors Stockfish). + /// Internal so can share the single import resolver. /// - private static partial class NativeMethods + internal static partial class NativeMethods { private const string LibName = "chess_engine"; @@ -115,5 +132,31 @@ public sealed partial class CustomChessEngine : IChessEngine [LibraryImport(LibName)] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] internal static partial void engine_destroy(IntPtr engine); + + // ---- Learned-weights / training ABI (see chess_engine.h) ---- + + [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial void learned_load(string path); + + [LibraryImport(LibName)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static unsafe partial int weights_snapshot(int* outBuf, int outLen); + + [LibraryImport(LibName)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial IntPtr trainer_create(); + + [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial void trainer_record(IntPtr trainer, string fen); + + [LibraryImport(LibName)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial void trainer_apply(IntPtr trainer, int winner, double weight); + + [LibraryImport(LibName)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial void trainer_destroy(IntPtr trainer); } } diff --git a/JoshHeaps.Net/Services/Implementations/LearnedWeightsStore.cs b/JoshHeaps.Net/Services/Implementations/LearnedWeightsStore.cs new file mode 100644 index 0000000..992f387 --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/LearnedWeightsStore.cs @@ -0,0 +1,64 @@ +using JoshHeaps.Net.Models; +using JoshHeaps.Net.Services.Interfaces; + +namespace JoshHeaps.Net.Services.Implementations; + +/// +/// Managed facade over the native learned-weights model (see ). +/// On construction it points the native engine at the weights file; everything else delegates +/// to the shared native ABI in . +/// +public sealed class LearnedWeightsStore : ILearnedWeightsStore +{ + private const int Pieces = 6; // Pawn..King + private const int Squares = 64; + private const int Features = 8; // mobility N/B/R/Q, passed, isolated, doubled, king safety + + public string WeightsFilePath { get; } + + public LearnedWeightsStore(IHostEnvironment env) + { + WeightsFilePath = Path.Combine(env.ContentRootPath, "chess-data", "learned-weights.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(WeightsFilePath)!); + CustomChessEngine.NativeMethods.learned_load(WeightsFilePath); + } + + public nint CreateTrainer() => CustomChessEngine.NativeMethods.trainer_create(); + + public void Record(nint trainer, string fen) => + CustomChessEngine.NativeMethods.trainer_record(trainer, fen); + + public void ApplyResult(nint trainer, PieceColor winner, double weight) => + CustomChessEngine.NativeMethods.trainer_apply(trainer, winner == PieceColor.White ? 0 : 1, weight); + + public void DestroyTrainer(nint trainer) => + CustomChessEngine.NativeMethods.trainer_destroy(trainer); + + public LearnedWeightsSnapshot Snapshot() + { + const int total = Pieces * Squares * 2 + Features; + var buffer = new int[total]; + + unsafe + { + fixed (int* p = buffer) + CustomChessEngine.NativeMethods.weights_snapshot(p, total); + } + + var mg = new int[Pieces][]; + var eg = new int[Pieces][]; + + for (int piece = 0; piece < Pieces; piece++) + { + mg[piece] = new int[Squares]; + eg[piece] = new int[Squares]; + Array.Copy(buffer, piece * Squares, mg[piece], 0, Squares); + Array.Copy(buffer, Pieces * Squares + piece * Squares, eg[piece], 0, Squares); + } + + var features = new int[Features]; + Array.Copy(buffer, Pieces * Squares * 2, features, 0, Features); + + return new LearnedWeightsSnapshot(mg, eg, features); + } +} diff --git a/JoshHeaps.Net/Services/Interfaces/IChessEngineFactory.cs b/JoshHeaps.Net/Services/Interfaces/IChessEngineFactory.cs index 3f87e24..02b4990 100644 --- a/JoshHeaps.Net/Services/Interfaces/IChessEngineFactory.cs +++ b/JoshHeaps.Net/Services/Interfaces/IChessEngineFactory.cs @@ -1,3 +1,5 @@ +using JoshHeaps.Net.Services.Implementations; + namespace JoshHeaps.Net.Services.Interfaces; /// @@ -6,9 +8,20 @@ namespace JoshHeaps.Net.Services.Interfaces; public interface IChessEngineFactory { /// - /// Creates a new engine instance for a single game. The caller owns and disposes it. + /// Creates a new engine instance for a single game using the configured default engine. + /// The caller owns and disposes it. /// /// The desired playing strength / search depth. /// A new, owned . IChessEngine Create(int skill); + + /// + /// Creates a new engine instance for a single game using an explicitly chosen engine + /// (e.g. for picking a different engine per side in a CPU-vs-CPU game). The caller owns + /// and disposes it. + /// + /// The desired playing strength / search depth. + /// The engine implementation to create. + /// A new, owned . + IChessEngine Create(int skill, ChessEngineKind kind); } diff --git a/JoshHeaps.Net/Services/Interfaces/IComputerMoveOrchestrator.cs b/JoshHeaps.Net/Services/Interfaces/IComputerMoveOrchestrator.cs index 6fcd34d..a4aa7ff 100644 --- a/JoshHeaps.Net/Services/Interfaces/IComputerMoveOrchestrator.cs +++ b/JoshHeaps.Net/Services/Interfaces/IComputerMoveOrchestrator.cs @@ -3,16 +3,24 @@ using JoshHeaps.Net.Models; namespace JoshHeaps.Net.Services.Interfaces; /// -/// Drives a computer move: asks the engine for a move, applies it through the rules -/// service, and broadcasts the result to the game's clients. +/// Drives a computer move: asks the side-to-move's engine for a move, applies it through +/// the rules service, and broadcasts the result to the game's clients. /// public interface IComputerMoveOrchestrator { /// - /// Has the engine pick a move for the current position, applies it, and broadcasts it. + /// Has the side-to-move's engine pick a move for the current position, applies it, and + /// broadcasts it. The engine is taken from the game's per-side computer assignments. /// /// The game to play a move in. - /// The engine that selects the move. /// The applied move and its result. - Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine); + Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state); + + /// + /// Plays a uniformly-random legal move for the side to move (no engine), applying and + /// broadcasting it. Used to randomize the opening of training games so self-play and + /// engine-vs-engine games don't replay the same line every time. + /// + /// The game to play a random move in. + Task PlayRandomMoveAsync(GameState state); } diff --git a/JoshHeaps.Net/Services/Interfaces/ILearnedWeightsStore.cs b/JoshHeaps.Net/Services/Interfaces/ILearnedWeightsStore.cs new file mode 100644 index 0000000..b0d3491 --- /dev/null +++ b/JoshHeaps.Net/Services/Interfaces/ILearnedWeightsStore.cs @@ -0,0 +1,33 @@ +using JoshHeaps.Net.Models; + +namespace JoshHeaps.Net.Services.Interfaces; + +/// +/// Thin managed facade over the native learned-weights model. The weights, all feature +/// computation, per-game accumulation, the update rule, and persistence live in the native +/// engine; this just points it at the weights file, hands out per-game trainers, and reads +/// the table back for visualization. +/// +public interface ILearnedWeightsStore +{ + /// Absolute path to the weights file the native engine loads and saves. + string WeightsFilePath { get; } + + /// A copy of the current weights (midgame/endgame tables + feature weights). + LearnedWeightsSnapshot Snapshot(); + + /// Creates a per-game training accumulator. The caller owns it (see ). + nint CreateTrainer(); + + /// Records one played position (post-move FEN) into a trainer. + void Record(nint trainer, string fen); + + /// + /// Applies a finished game's outcome to the global weights (and saves): rewards the + /// winner's squares/features, punishes the loser's, scaled by . + /// + void ApplyResult(nint trainer, PieceColor winner, double weight); + + /// Frees a trainer. Safe to call with . + void DestroyTrainer(nint trainer); +} diff --git a/JoshHeaps.Net/wwwroot/css/chess/game.css b/JoshHeaps.Net/wwwroot/css/chess/game.css index 88640c4..23b61b8 100644 --- a/JoshHeaps.Net/wwwroot/css/chess/game.css +++ b/JoshHeaps.Net/wwwroot/css/chess/game.css @@ -157,6 +157,47 @@ pointer-events: none; /* ensures img doesn't steal the click */ } +#colorModal { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: #1e1e1e; + padding: 20px; + border-radius: 10px; + box-shadow: 0 0 20px rgba(0,0,0,0.6); + color: white; + z-index: 1000; + text-align: center; +} + +#colorModal p { + margin-bottom: 15px; + font-size: 18px; + font-weight: bold; +} + +#colorButtonContainer { + display: flex; + gap: 10px; + justify-content: center; +} + +#colorButtonContainer button { + background-color: #2c2c2c; + border: none; + padding: 10px 18px; + border-radius: 8px; + cursor: pointer; + transition: transform 0.2s ease; + color: white; +} + +#colorButtonContainer button:hover { + transform: scale(1.1); + background-color: #3a3a3a; +} + .chessSquare .coordinate-label { position: absolute; font-size: 1.2vw; diff --git a/JoshHeaps.Net/wwwroot/css/chess/spectate.css b/JoshHeaps.Net/wwwroot/css/chess/spectate.css index d47db37..297fe93 100644 --- a/JoshHeaps.Net/wwwroot/css/chess/spectate.css +++ b/JoshHeaps.Net/wwwroot/css/chess/spectate.css @@ -21,11 +21,27 @@ html, body { display: flex; align-items: center; justify-content: center; - gap: 0.5rem; + flex-wrap: wrap; + gap: 0.75rem; margin: 1rem 0; } -#cpuDifficulty { +.enginePicker { + display: flex; + align-items: center; + gap: 0.4rem; + border: 1px solid #444; + border-radius: 8px; + padding: 0.3rem 0.6rem 0.5rem; +} + +.enginePicker legend { + color: #9a9a9a; + font-size: 0.8rem; + padding: 0 0.3rem; +} + +#watchControls select { background-color: #1e1e1e; color: #d6d6d6; border: 1px solid #444; diff --git a/JoshHeaps.Net/wwwroot/css/chess/weights.css b/JoshHeaps.Net/wwwroot/css/chess/weights.css new file mode 100644 index 0000000..91e2a90 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/css/chess/weights.css @@ -0,0 +1,187 @@ +body { + margin: 0; + background-color: #141414; + color: #d6d6d6; + font-family: "Segoe UI", system-ui, sans-serif; +} + +#weightsHeader { + text-align: center; + padding: 1.5rem 1rem 0.5rem; +} + +#weightsHeader h1 { + margin: 0 0 0.25rem; + font-size: 1.6rem; +} + +#weightsStatus { + color: #9a9a9a; + margin: 0.25rem 0 1rem; +} + +#weightsControls { + display: flex; + align-items: center; + justify-content: center; + gap: 1.25rem; + margin-bottom: 0.5rem; +} + +.heatLegend { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + color: #9a9a9a; +} + +.legendBar { + width: 120px; + height: 10px; + border-radius: 5px; + background: linear-gradient(to right, rgba(232, 74, 74, 1), #2a2a2a, rgba(74, 134, 232, 1)); +} + +#refreshWeights { + background-color: #8cd5ed; + color: #262626; + border: 0; + border-radius: 30px; + padding: 0.5rem 1.1rem; + cursor: pointer; +} + +#refreshWeights:hover { + background-color: #a5e0f2; +} + +#backToWatch { + display: inline-block; + margin-top: 0.5rem; + color: #8cd5ed; + text-decoration: none; +} + +.weightsSection { + margin: 0 auto; + max-width: 1200px; + padding: 0.5rem 1rem; +} + +.weightsSection h2 { + text-align: center; + font-size: 1.2rem; + margin: 1rem 0 0.25rem; +} + +.sectionHint { + text-align: center; + color: #9a9a9a; + font-size: 0.8rem; + margin: 0 0 0.75rem; +} + +.weightsGrid { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1.25rem; + padding: 0.5rem 0; +} + +.featurePanel { + max-width: 520px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.featureRow { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.featureLabel { + flex: 0 0 6.5rem; + font-size: 0.8rem; + color: #c8c8c8; + text-align: right; +} + +.featureTrack { + flex: 1 1 auto; + height: 14px; + background-color: #232323; + border-radius: 7px; + overflow: hidden; +} + +.featureBar { + height: 100%; + border-radius: 7px; + min-width: 2px; +} + +.featureValue { + flex: 0 0 3rem; + font-size: 0.8rem; + color: #e8e8e8; + text-align: left; +} + +.weightBoard { + background-color: #1c1c1c; + border: 1px solid #2e2e2e; + border-radius: 10px; + padding: 0.75rem; +} + +.weightBoardHeader { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + font-weight: 600; +} + +.weightBoardIcon { + width: 22px; + height: 22px; +} + +.weightRange { + margin-left: auto; + font-weight: 400; + font-size: 0.75rem; + color: #888; +} + +.miniHeat { + display: grid; + grid-template-columns: 1.1rem repeat(8, 34px); +} + +.heatSquare { + width: 34px; + height: 34px; + box-sizing: border-box; + border: 1px solid #2a2a2a; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.62rem; + color: #f0f0f0; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); +} + +.heatLabel { + display: flex; + align-items: center; + justify-content: center; + padding: 2px 0; + font-size: 0.6rem; + color: #777; +} diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js index 19f872d..06e0d7a 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js @@ -4,8 +4,8 @@ const ChessAPI = { return await response.json(); }, - async createCPUGame(difficulty) { - const response = await fetch(`/api/chess/new/${difficulty}`); + async createCPUGame(difficulty, color = "random") { + const response = await fetch(`/api/chess/new/${difficulty}?color=${color}`); return await response.json(); }, diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessModals.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessModals.js index b8a78f2..935f1ac 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessModals.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessModals.js @@ -23,6 +23,16 @@ const ChessModals = { resolve(difficulty); }; }); + }, + + promptColor() { + return new Promise(resolve => { + document.getElementById("colorModal").style.display = "block"; + window.selectColor = (color) => { + document.getElementById("colorModal").style.display = "none"; + resolve(color); + }; + }); } }; diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js index 08f3450..d576798 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js @@ -27,10 +27,15 @@ const Spectate = { }, async startCpuGame() { - const difficulty = document.getElementById("cpuDifficulty").value; + const params = new URLSearchParams({ + whiteEngine: document.getElementById("whiteEngine").value, + whiteSkill: document.getElementById("whiteSkill").value, + blackEngine: document.getElementById("blackEngine").value, + blackSkill: document.getElementById("blackSkill").value + }); try { - await fetch(`/api/chess/watch/cpu/${difficulty}`); + await fetch(`/api/chess/watch/cpu?${params}`); await this.refreshGames(); } catch (err) { console.error("❌ Could not start CPU vs CPU game.", err); @@ -68,7 +73,9 @@ const Spectate = { async addGame(game) { this.games.set(game.gameId, { isVsComputer: game.isVsComputer, - isComputerVsComputer: game.isComputerVsComputer + isComputerVsComputer: game.isComputerVsComputer, + whiteEngine: game.whiteEngine, + blackEngine: game.blackEngine }); const card = document.createElement("div"); @@ -183,11 +190,21 @@ const Spectate = { }, gameLabel(stored) { - if (stored.isComputerVsComputer) return "CPU vs CPU"; + if (stored.isComputerVsComputer) + return `${this.engineName(stored.whiteEngine)} (W) vs ${this.engineName(stored.blackEngine)} (B)`; if (stored.isVsComputer) return "Vs CPU"; return "Player vs Player"; }, + engineName(kind) { + switch (kind) { + case "CustomLearned": return "Learned"; + case "Custom": return "Custom"; + case "Stockfish": return "Stockfish"; + default: return kind || "CPU"; + } + }, + headerText(stored, currentPlayer, moveCount, isCheck) { if (stored.result) return `${this.gameLabel(stored)} · move ${moveCount} · final`; diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/Weights.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/Weights.js new file mode 100644 index 0000000..4dff00b --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/Weights.js @@ -0,0 +1,158 @@ +const Weights = { + async init() { + await this.refresh(); + }, + + async refresh() { + let data; + + try { + const response = await fetch("/api/chess/weights"); + data = await response.json(); + } catch (err) { + console.error("❌ Could not load weights.", err); + document.getElementById("weightsStatus").textContent = "Could not load weights."; + return; + } + + this.renderBoardSet(data.mg, "weightsGridMg"); + this.renderBoardSet(data.eg, "weightsGridEg"); + this.renderFeatures(data.features); + + const trained = [...data.mg, ...data.eg].some(b => b.squares.some(v => v !== 0)) + || data.features.some(f => f.value !== 0); + + document.getElementById("weightsStatus").textContent = trained + ? "Where the learned engine thinks each piece belongs. Blue = preferred, red = avoided. Midgame vs endgame tables are blended by how much material is left." + : "No training yet — everything is neutral. Run some learned games on the Watch page."; + }, + + // pieces: [{ name, squares[64] }]. Prepends an "Overall" board summing the set. + renderBoardSet(pieces, containerId) { + const overall = new Array(64).fill(0); + for (const piece of pieces) + for (let sq = 0; sq < 64; sq++) + overall[sq] += piece.squares[sq]; + + const boards = [{ name: "Overall", squares: overall }, ...pieces]; + const grid = document.getElementById(containerId); + grid.innerHTML = ""; + boards.forEach(board => grid.appendChild(this.buildBoard(board))); + }, + + buildBoard(board) { + const wrapper = document.createElement("div"); + wrapper.className = "weightBoard"; + + const maxAbs = board.squares.reduce((m, v) => Math.max(m, Math.abs(v)), 0); + + const header = document.createElement("div"); + header.className = "weightBoardHeader"; + if (board.name !== "Overall") { + const icon = document.createElement("img"); + icon.src = `/images/Chess Images/White${board.name}.svg`; + icon.alt = board.name; + icon.className = "weightBoardIcon"; + header.appendChild(icon); + } + const title = document.createElement("span"); + title.textContent = board.name; + header.appendChild(title); + const range = document.createElement("span"); + range.className = "weightRange"; + range.textContent = maxAbs === 0 ? "neutral" : `±${maxAbs}`; + header.appendChild(range); + wrapper.appendChild(header); + + const heat = document.createElement("div"); + heat.className = "miniHeat"; + + for (let row = 0; row < 8; row++) { + const rankLabel = document.createElement("div"); + rankLabel.className = "heatLabel"; + rankLabel.textContent = 8 - row; // rank 8 at top, 1 at bottom + heat.appendChild(rankLabel); + + for (let col = 0; col < 8; col++) { + const rank = 7 - row; // rank index, 0 = rank 1 + const sq = rank * 8 + col; // white-relative square (A1 = 0) + heat.appendChild(this.buildSquare(board.squares[sq], sq, maxAbs)); + } + } + + heat.appendChild(this.cornerSpacer()); + for (let col = 0; col < 8; col++) { + const fileLabel = document.createElement("div"); + fileLabel.className = "heatLabel"; + fileLabel.textContent = String.fromCharCode(97 + col); + heat.appendChild(fileLabel); + } + + wrapper.appendChild(heat); + return wrapper; + }, + + buildSquare(value, sq, maxAbs) { + const cell = document.createElement("div"); + cell.className = "heatSquare"; + + if (value !== 0 && maxAbs > 0) { + const ratio = Math.abs(value) / maxAbs; + const alpha = (0.12 + 0.88 * ratio).toFixed(3); + cell.style.backgroundColor = value > 0 + ? `rgba(74, 134, 232, ${alpha})` // high -> blue + : `rgba(232, 74, 74, ${alpha})`; // low -> red + cell.textContent = value; + } + + const file = String.fromCharCode(97 + (sq & 7)); + const rank = (sq >> 3) + 1; + cell.title = `${file}${rank}: ${value}`; + return cell; + }, + + cornerSpacer() { + const spacer = document.createElement("div"); + spacer.className = "heatLabel"; + return spacer; + }, + + // features: [{ name, value }] + renderFeatures(features) { + const panel = document.getElementById("featureWeights"); + panel.innerHTML = ""; + + const maxAbs = features.reduce((m, f) => Math.max(m, Math.abs(f.value)), 0); + + features.forEach(feature => { + const row = document.createElement("div"); + row.className = "featureRow"; + + const label = document.createElement("span"); + label.className = "featureLabel"; + label.textContent = feature.name; + + const track = document.createElement("div"); + track.className = "featureTrack"; + const bar = document.createElement("div"); + bar.className = "featureBar"; + const ratio = maxAbs === 0 ? 0 : Math.abs(feature.value) / maxAbs; + bar.style.width = `${(ratio * 100).toFixed(1)}%`; + bar.style.backgroundColor = feature.value >= 0 + ? "rgba(74, 134, 232, 0.85)" + : "rgba(232, 74, 74, 0.85)"; + track.appendChild(bar); + + const value = document.createElement("span"); + value.className = "featureValue"; + value.textContent = feature.value; + + row.append(label, track, value); + panel.appendChild(row); + }); + } +}; + +window.addEventListener("load", () => Weights.init()); + +console.log("Weights.js loaded"); diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js index 826816f..0f48029 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js @@ -91,7 +91,8 @@ async function startCPUGame() { try { const difficulty = await ChessModals.promptDifficulty(); - const gameData = await ChessAPI.createCPUGame(difficulty); + const color = await ChessModals.promptColor(); + const gameData = await ChessAPI.createCPUGame(difficulty, color); GameState.setGameInfo(gameData.gameId, gameData.id, gameData.isWhite); GameState.clearPreviousMove(); diff --git a/native/chess_engine/include/chess_engine.h b/native/chess_engine/include/chess_engine.h index 9caca72..e8af337 100644 --- a/native/chess_engine/include/chess_engine.h +++ b/native/chess_engine/include/chess_engine.h @@ -32,6 +32,9 @@ extern "C" { /* prevent C++ name mangling */ * Internally it points to your engine state object. */ typedef struct ChessEngine* EngineHandle; +/* Opaque per-game training accumulator (see the learned-weights ABI at the bottom). */ +typedef struct Trainer* TrainerHandle; + /* Return codes. 0 == success; negative == error. Keep these values stable. */ enum { CHESS_OK = 0, @@ -79,6 +82,32 @@ CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len); /* Destroy an instance created by engine_create. Safe to call with NULL. */ CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine); +/* ---- Learned-weights / training ABI ---- + * The learned engine's weights live process-globally here. The host orchestrates games but + * owns no chess logic: it points the engine at the weights file, records each played + * position, and applies the game's result. */ + +/* Load the global learned weights from `path` and remember it for later saves. Idempotent; + * a missing/short file leaves the weights neutral. Call once before learned play/training. */ +CHESS_API void CHESS_CALL learned_load(const char* path); + +/* Copy the global weights out for visualization: 6*64 midgame + 6*64 endgame (PAWN..KING, + * squares 0..63) + feature weights. Returns the count written, or CHESS_ERR_BUFFER if + * out_len is too small (needs >= 776). */ +CHESS_API int CHESS_CALL weights_snapshot(int* out, int out_len); + +/* Create / destroy a per-game training accumulator. Safe to destroy NULL. */ +CHESS_API TrainerHandle CHESS_CALL trainer_create(void); +CHESS_API void CHESS_CALL trainer_destroy(TrainerHandle trainer); + +/* Record one played position (post-move FEN) into the accumulator. */ +CHESS_API void CHESS_CALL trainer_record(TrainerHandle trainer, const char* fen); + +/* Apply a finished game's outcome to the global weights and save: rewards the winner's + * occupied squares / features and punishes the loser's, scaled by `weight` (e.g. 0.5 for a + * material-imbalance draw). winner: 0 = white, 1 = black. */ +CHESS_API void CHESS_CALL trainer_apply(TrainerHandle trainer, int winner, double weight); + #ifdef __cplusplus } #endif diff --git a/native/chess_engine/src/chess_engine.cpp b/native/chess_engine/src/chess_engine.cpp index 740531e..c32a252 100644 --- a/native/chess_engine/src/chess_engine.cpp +++ b/native/chess_engine/src/chess_engine.cpp @@ -20,10 +20,12 @@ #include #include +#include #include #include #include #include +#include #include #include #include @@ -76,10 +78,63 @@ static chess::Move tt_move (uint64_t d) { return chess::Move(static_cast(static_cast(d >> 48)); } static Bound tt_bound(uint64_t d) { return static_cast(static_cast(d >> 56)); } -/* Internal engine state. One ChessEngine = one game. The table is NOT here: it is the - * shared g_tt above. */ +/* Eval variant for an engine handle. CLASSIC = the hand-crafted evaluate(); LEARNED = + * material + learned phase-split piece-square tables + learned feature weights. */ +enum EvalVariant : int { EVAL_CLASSIC = 0, EVAL_LEARNED = 1 }; + +/* The learned feature knobs (beyond the piece-square tables). Each has one weight learned + * from game outcomes; its activation is computed by compute_features(). Mobility is per + * piece type. Order is fixed — it is the on-disk and snapshot layout after the two tables. */ +enum Feature : int { + FEAT_MOB_N, FEAT_MOB_B, FEAT_MOB_R, FEAT_MOB_Q, /* legal-move counts, per piece type */ + FEAT_PASSED, /* passed pawns, endgame-weighted */ + FEAT_ISOLATED, /* isolated pawns */ + FEAT_DOUBLED, /* doubled pawns */ + FEAT_KING, /* king pawn-shelter, midgame-weighted */ + FEATURE_NB +}; + +/* Per-handle eval configuration, snapshotted from the global learned weights at + * engine_create so the search reads a stable copy. The tables are white-relative: a black + * piece indexes the rank-mirrored square (sq ^ 56). `mg`/`eg` are blended by game phase. + * Indexed by chess::PieceType (PAWN..KING). Only consulted when variant == EVAL_LEARNED. */ +struct EvalParams { + int variant = EVAL_CLASSIC; + int mg[chess::PIECE_TYPE_NB][64] = {}; + int eg[chess::PIECE_TYPE_NB][64] = {}; + int featW[FEATURE_NB] = {}; +}; + +/* Internal engine state. One ChessEngine = one game. The transposition table is NOT + * here: it is the shared g_tt above. */ struct ChessEngine { - int skill = 20; /* 1..20 from the UI; controls search depth */ + int skill = 20; /* 1..20 from the UI; controls search depth */ + EvalParams eval; /* which evaluation the search uses, plus any learned weights */ +}; + +/* The process-global learned weights: the single source of truth, loaded from disk once and + * updated in place by training. Engine handles snapshot it at creation; the visualization + * snapshots it on demand. Guarded by g_weightsMutex for updates/saves (eval reads its own + * per-handle copy, so it never touches this concurrently). */ +struct LearnedWeights { + int mg[chess::PIECE_TYPE_NB][64] = {}; + int eg[chess::PIECE_TYPE_NB][64] = {}; + int featW[FEATURE_NB] = {}; +}; + +static LearnedWeights g_weights; +static std::mutex g_weightsMutex; +static std::string g_weightsPath; + +/* Per-game training accumulator (one per learned CPU-vs-CPU game). Records, per ply, where + * each side's pieces sat (split into midgame/endgame by phase) and each side's feature + * activations; trainer_apply turns the totals into weight nudges. Squares are white-relative + * (black indexes sq ^ 56), so a side's tally lines up with the shared white-relative table. */ +struct Trainer { + double mgOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {}; + double egOcc[chess::COLOR_NB][chess::PIECE_TYPE_NB][64] = {}; + double featAcc[chess::COLOR_NB][FEATURE_NB] = {}; + int plies = 0; }; static int copy_out(const char* src, char* out_buf, int out_len) { @@ -108,6 +163,48 @@ static int parse_skill(const char* options, int fallback) { return v < 1 ? 1 : v > 20 ? 20 : v; } +/* "variant=learned" in the options selects the learned eval; anything else is classic. */ +static int parse_variant(const char* options) { + if (!options) return EVAL_CLASSIC; + const char* p = std::strstr(options, "variant="); + if (!p) return EVAL_CLASSIC; + return std::strncmp(p + 8, "learned", 7) == 0 ? EVAL_LEARNED : EVAL_CLASSIC; +} + +/* On-disk format: 6*64 mg ints (PAWN..KING, squares 0..63), then 6*64 eg ints, then + * FEATURE_NB feature ints, whitespace-separated. A missing file or short read leaves the + * rest neutral (0), so an absent weights file just means "train from a blank slate". + * Caller holds g_weightsMutex. */ +static void load_global_weights(const char* path) { + g_weights = LearnedWeights{}; /* reset to neutral before loading */ + + if (!path || !*path) return; + std::ifstream f(path); + if (!f) return; + + for (int pt = chess::PAWN; pt <= chess::KING; ++pt) + for (int sq = 0; sq < 64; ++sq) + if (!(f >> g_weights.mg[pt][sq])) return; + for (int pt = chess::PAWN; pt <= chess::KING; ++pt) + for (int sq = 0; sq < 64; ++sq) + if (!(f >> g_weights.eg[pt][sq])) return; + for (int i = 0; i < FEATURE_NB; ++i) + if (!(f >> g_weights.featW[i])) return; +} + +/* Persist g_weights to g_weightsPath in the format load_global_weights reads. Caller holds the lock. */ +static void save_global_weights() { + if (g_weightsPath.empty()) return; + std::ofstream f(g_weightsPath); + if (!f) return; + + for (int pt = chess::PAWN; pt <= chess::KING; ++pt) + for (int sq = 0; sq < 64; ++sq) f << g_weights.mg[pt][sq] << (sq == 63 ? '\n' : ' '); + for (int pt = chess::PAWN; pt <= chess::KING; ++pt) + for (int sq = 0; sq < 64; ++sq) f << g_weights.eg[pt][sq] << (sq == 63 ? '\n' : ' '); + for (int i = 0; i < FEATURE_NB; ++i) f << g_weights.featW[i] << (i == FEATURE_NB - 1 ? '\n' : ' '); +} + /* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no * quiescence yet, so deep fixed-depth runs get expensive quickly. */ static int depth_for_skill(int skill) { @@ -208,14 +305,32 @@ static int evaluatePawn(const chess::Position& pos, const chess::Color c, const return score; } +static int piece_value(chess::PieceType pt) { + switch (pt) { + case chess::PAWN: return 100; + case chess::KNIGHT: return 320; + case chess::BISHOP: return 330; + case chess::ROOK: return 500; + case chess::QUEEN: return 900; + default: return 0; + } +} + static int castleIncentive(const chess::Position& pos, chess::Color c) { - if (!pos.pieces(chess::QUEEN)) - return 0; + chess::Bitboard pcs = pos.pieces(); + int total = 0; + while (pcs) { + chess::Square s = chess::pop_lsb(pcs); + chess::Piece pc = pos.piece_on(s); + chess::Color c = chess::color_of(pc); + total += piece_value(chess::type_of(pc)); + } + chess::Square k = pos.king_square(c); bool castled = (c == chess::WHITE) ? (k == chess::G1 || k == chess::C1) : (k == chess::G8 || k == chess::C8); - return castled ? 600 : 0; + return castled ? (total / 10) : 0; } static int evaluatePiece(const chess::Position& pos, const chess::Square& s, const chess::Piece& pc, const chess::Color& c) { @@ -261,10 +376,136 @@ static int evaluate(const chess::Position& pos) { return score; } +/* ---- Learned (phase-split tables + feature knobs) evaluation --------------------------- + * The model is a linear combination of features whose weights are learned from outcomes: + * eval = Σ pieces [ material + blend(mg, eg, phase) ] + Σ features featW[i]·activation[i] + * compute_features() is the single source of feature activations, used by BOTH the eval here + * and the trainer, so the two can never disagree. Constants below are the only tunables. */ + +/* Per-game-outcome learning rates and clamps. Squares accumulate occupancy (plies on a + * square, summed); features accumulate normalized per-ply activation (averaged, divided by a + * nominal scale so high-magnitude mobility doesn't dwarf the small pawn-structure terms). */ +static constexpr double SQUARE_LR = 0.5; +static constexpr int SQ_CLAMP = 250; +static constexpr double FEAT_LR = 2.0; +static constexpr int FEAT_CLAMP = 500; +static constexpr double FEAT_SCALE[FEATURE_NB] = { 4, 6, 8, 14, 2, 1, 1, 2 }; + +/* Game phase in [0,1] from remaining non-pawn material (PeSTO weights N=B=1, R=2, Q=4; max + * 24 for both full sides): 0 = opening, 1 = bare kings. Drives the mg/eg table blend and + * the phase weighting of the passed-pawn (×phase) and king-safety (×(1−phase)) features. */ +static double game_phase(const chess::Position& pos) { + int npm = chess::popcount(pos.pieces(chess::KNIGHT)) * 1 + + chess::popcount(pos.pieces(chess::BISHOP)) * 1 + + chess::popcount(pos.pieces(chess::ROOK)) * 2 + + chess::popcount(pos.pieces(chess::QUEEN)) * 4; + constexpr int MAX = 24; + if (npm >= MAX) return 0.0; + return double(MAX - npm) / MAX; +} + +/* Blend a midgame and endgame value by phase, rounding per-piece (so training credits a + * square the same way the eval reads it). */ +static int blend(int mg, int eg, double phase) { + return int(std::lround((1.0 - phase) * mg + phase * eg)); +} + +/* Fills `out[FEATURE_NB]` with one color's raw feature activations for a position. The piece- + * square tables handle "where pieces belong"; these capture context a static table can't: + * legal mobility (per piece type, so pins reduce it), passed pawns (endgame-weighted), pawn + * structure, and king shelter (midgame-weighted). Ported nowhere — this is the only copy. */ +static void compute_features(chess::Position& pos, chess::Color c, double phase, double out[FEATURE_NB]) { + for (int i = 0; i < FEATURE_NB; ++i) out[i] = 0.0; + + /* Mobility: legal moves for color c, bucketed by the moving piece's type. */ + chess::MoveList moves; + pos.generate_legal_for(c, moves); + for (int i = 0; i < moves.size(); ++i) { + switch (chess::type_of(pos.piece_on(moves.moves[i].from()))) { + case chess::KNIGHT: out[FEAT_MOB_N] += 1; break; + case chess::BISHOP: out[FEAT_MOB_B] += 1; break; + case chess::ROOK: out[FEAT_MOB_R] += 1; break; + case chess::QUEEN: out[FEAT_MOB_Q] += 1; break; + default: break; + } + } + + /* Pawn structure. */ + chess::Bitboard pawns = pos.pieces(c, chess::PAWN); + chess::Bitboard bb = pawns; + while (bb) { + chess::Square s = chess::pop_lsb(bb); + + if (!(front_span(c, s) & pos.pieces(~c, chess::PAWN))) { /* passed */ + chess::Rank r = chess::rank_of(s); + int toPromotion = (c == chess::WHITE) ? (chess::RANK_8 - r) : (r - chess::RANK_1); + out[FEAT_PASSED] += (6 - toPromotion) * phase; /* 0..5 ranks advanced, late-game */ + } + if (front_span_file_only(c, s) & pawns) /* doubled (friendly pawn ahead) */ + out[FEAT_DOUBLED] += 1; + + chess::File f = chess::file_of(s); + chess::Bitboard adjacent = 0; + if (f > chess::FILE_A) adjacent |= chess::file_bb(chess::File(f - 1)); + if (f < chess::FILE_H) adjacent |= chess::file_bb(chess::File(f + 1)); + if (!(adjacent & pawns)) /* isolated */ + out[FEAT_ISOLATED] += 1; + } + + /* King safety: friendly pawns sheltering the king (its file + adjacent files, the two + * ranks in front), worth more in the midgame. */ + chess::Square k = pos.king_square(c); + chess::File kf = chess::file_of(k); + chess::Rank kr = chess::rank_of(k); + chess::Bitboard kingFiles = chess::file_bb(kf); + if (kf > chess::FILE_A) kingFiles |= chess::file_bb(chess::File(kf - 1)); + if (kf < chess::FILE_H) kingFiles |= chess::file_bb(chess::File(kf + 1)); + chess::Bitboard shelterRanks = 0; + for (int d = 1; d <= 2; ++d) { + int rr = (c == chess::WHITE) ? (kr + d) : (kr - d); + if (rr >= 0 && rr <= 7) shelterRanks |= (0xFFULL << (8 * rr)); + } + out[FEAT_KING] += chess::popcount(kingFiles & shelterRanks & pawns) * (1.0 - phase); +} + +/* Learned eval (white-positive/absolute, like evaluate()): material + phase-blended piece- + * square tables + learned feature weights. Black pieces index the rank-mirrored square + * (s ^ 56) so both colors share one white-relative table. Non-const because mobility + * generates legal moves (which the position's move generator does via do/undo). */ +static int evaluateLearned(chess::Position& pos, const EvalParams& ep) { + double phase = game_phase(pos); + int score = 0; + + chess::Bitboard white = pos.pieces(chess::WHITE); + while (white) { + chess::Square s = chess::pop_lsb(white); + chess::PieceType pt = chess::type_of(pos.piece_on(s)); + score += piece_value(pt) + blend(ep.mg[pt][s], ep.eg[pt][s], phase); + } + + chess::Bitboard black = pos.pieces(chess::BLACK); + while (black) { + chess::Square s = chess::pop_lsb(black); + chess::PieceType pt = chess::type_of(pos.piece_on(s)); + score -= piece_value(pt) + blend(ep.mg[pt][s ^ 56], ep.eg[pt][s ^ 56], phase); + } + + double wFeat[FEATURE_NB], bFeat[FEATURE_NB]; + compute_features(pos, chess::WHITE, phase, wFeat); + compute_features(pos, chess::BLACK, phase, bFeat); + + double feature = 0.0; + for (int i = 0; i < FEATURE_NB; ++i) + feature += ep.featW[i] * (wFeat[i] - bFeat[i]) / FEAT_SCALE[i]; + score += int(std::lround(feature)); + + return score; +} + /* evaluate() is white-positive (absolute). Negamax needs it relative to the side to * move, so flip the sign when black is to move. */ -static int evaluate_stm(const chess::Position& pos, bool whiteToMove) { - int s = evaluate(pos); +static int evaluate_stm(chess::Position& pos, bool whiteToMove, const EvalParams& ep) { + int s = (ep.variant == EVAL_LEARNED) ? evaluateLearned(pos, ep) : evaluate(pos); return whiteToMove ? s : -s; } @@ -274,17 +515,6 @@ static int evaluate_stm(const chess::Position& pos, bool whiteToMove) { static int score_to_tt(int s, int ply) { return s >= MATE_BOUND ? s + ply : s <= -MATE_BOUND ? s - ply : s; } static int score_from_tt(int s, int ply) { return s >= MATE_BOUND ? s - ply : s <= -MATE_BOUND ? s + ply : s; } -static int piece_value(chess::PieceType pt) { - switch (pt) { - case chess::PAWN: return 100; - case chess::KNIGHT: return 320; - case chess::BISHOP: return 330; - case chess::ROOK: return 500; - case chess::QUEEN: return 900; - default: return 0; - } -} - /* Heuristic for searching the most promising moves first, which makes alpha-beta prune far * more. Bands, highest first: the TT best move, then captures by MVV-LVA (most valuable * victim, least valuable attacker), then the two killer moves for this ply (quiet moves that @@ -351,6 +581,15 @@ CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) { auto* e = new (std::nothrow) ChessEngine(); if (!e) return nullptr; e->skill = parse_skill(options, e->skill); + e->eval.variant = parse_variant(options); + if (e->eval.variant == EVAL_LEARNED) { + /* Snapshot the current global weights so the search reads a stable copy (training + * updates the global between games; the weights path is owned by learned_load). */ + std::lock_guard lock(g_weightsMutex); + std::memcpy(e->eval.mg, g_weights.mg, sizeof e->eval.mg); + std::memcpy(e->eval.eg, g_weights.eg, sizeof e->eval.eg); + std::memcpy(e->eval.featW, g_weights.featW, sizeof e->eval.featW); + } return e; } @@ -369,8 +608,9 @@ CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine, static constexpr int MAX_PLY = 128; /* ply never exceeds maxDepth (<= 20) */ struct SearchContext { - uint64_t nodes = 0; - chess::Move killers[MAX_PLY][2] = {}; /* [ply][slot]; MOVE_NONE until filled */ + uint64_t nodes = 0; + const EvalParams* eval = nullptr; /* eval config for this search; set by engine_best_move */ + chess::Move killers[MAX_PLY][2] = {};/* [ply][slot]; MOVE_NONE until filled */ }; /* Negamax alpha-beta over the shared transposition table. `maxDepth` is the searching @@ -387,7 +627,7 @@ static int negamax(chess::Position& pos, int maxDepth, int depth, int ply, return 0; if (depth <= 0) - return evaluate_stm(pos, whiteToMove); + return evaluate_stm(pos, whiteToMove, *ctx.eval); const uint64_t key = pos.key(); TTEntry& slot = g_tt.entries[key & g_tt.mask]; @@ -502,6 +742,7 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine, return CHESS_ERR_NO_MOVE; SearchContext ctx; + ctx.eval = &engine->eval; int maxDepth = depth_for_skill(engine->skill); chess::Move bestMove = moves.moves[0]; /* guaranteed-legal fallback */ @@ -547,4 +788,100 @@ CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine) { delete engine; /* delete nullptr is safe */ } +/* ---- Learned-weights / training C ABI -------------------------------------------------- + * The managed side orchestrates games but owns no chess logic: it tells the engine where + * to load/save the global weights, records each played position, and applies the result. */ + +CHESS_API void CHESS_CALL learned_load(const char* path) { + std::lock_guard lock(g_weightsMutex); + g_weightsPath = path ? path : ""; + load_global_weights(path); +} + +CHESS_API int CHESS_CALL weights_snapshot(int* out, int out_len) { + const int need = 6 * 64 * 2 + FEATURE_NB; /* mg + eg (PAWN..KING) + features = 776 */ + if (!out || out_len < need) return CHESS_ERR_BUFFER; + + std::lock_guard lock(g_weightsMutex); + int n = 0; + for (int pt = chess::PAWN; pt <= chess::KING; ++pt) + for (int sq = 0; sq < 64; ++sq) out[n++] = g_weights.mg[pt][sq]; + for (int pt = chess::PAWN; pt <= chess::KING; ++pt) + for (int sq = 0; sq < 64; ++sq) out[n++] = g_weights.eg[pt][sq]; + for (int i = 0; i < FEATURE_NB; ++i) out[n++] = g_weights.featW[i]; + return n; +} + +CHESS_API TrainerHandle CHESS_CALL trainer_create(void) { + return new (std::nothrow) Trainer(); +} + +CHESS_API void CHESS_CALL trainer_record(TrainerHandle t, const char* fen) { + if (!t || !fen || !*fen) return; + ensure_initialized(); + + chess::Position pos = chess::Position::from_fen(fen); + double phase = game_phase(pos); + + /* Per-square occupancy, split into midgame/endgame by phase, white-relative. */ + chess::Bitboard occ = pos.pieces(); + while (occ) { + chess::Square s = chess::pop_lsb(occ); + chess::Piece pc = pos.piece_on(s); + chess::Color c = chess::color_of(pc); + chess::PieceType pt = chess::type_of(pc); + int relSq = (c == chess::WHITE) ? int(s) : (int(s) ^ 56); + t->mgOcc[c][pt][relSq] += (1.0 - phase); + t->egOcc[c][pt][relSq] += phase; + } + + /* Per-side feature activations. */ + double w[FEATURE_NB], b[FEATURE_NB]; + compute_features(pos, chess::WHITE, phase, w); + compute_features(pos, chess::BLACK, phase, b); + for (int i = 0; i < FEATURE_NB; ++i) { + t->featAcc[chess::WHITE][i] += w[i]; + t->featAcc[chess::BLACK][i] += b[i]; + } + + t->plies++; +} + +CHESS_API void CHESS_CALL trainer_apply(TrainerHandle t, int winner, double weight) { + if (!t) return; + + std::lock_guard lock(g_weightsMutex); + + /* pass 0 = winner (reward, +1); pass 1 = loser (punish, -1). */ + for (int pass = 0; pass < 2; ++pass) { + chess::Color side = chess::Color((pass == 0 ? winner : (winner ^ 1)) & 1); + int sign = pass == 0 ? 1 : -1; + + for (int pt = chess::PAWN; pt <= chess::KING; ++pt) + for (int sq = 0; sq < 64; ++sq) { + if (t->mgOcc[side][pt][sq] != 0.0) { + int d = sign * int(std::lround(SQUARE_LR * t->mgOcc[side][pt][sq] * weight)); + g_weights.mg[pt][sq] = std::clamp(g_weights.mg[pt][sq] + d, -SQ_CLAMP, SQ_CLAMP); + } + if (t->egOcc[side][pt][sq] != 0.0) { + int d = sign * int(std::lround(SQUARE_LR * t->egOcc[side][pt][sq] * weight)); + g_weights.eg[pt][sq] = std::clamp(g_weights.eg[pt][sq] + d, -SQ_CLAMP, SQ_CLAMP); + } + } + + if (t->plies > 0) + for (int i = 0; i < FEATURE_NB; ++i) { + double avg = t->featAcc[side][i] / t->plies; /* per-ply average, normalized */ + int d = sign * int(std::lround(FEAT_LR * (avg / FEAT_SCALE[i]) * weight)); + g_weights.featW[i] = std::clamp(g_weights.featW[i] + d, -FEAT_CLAMP, FEAT_CLAMP); + } + } + + save_global_weights(); +} + +CHESS_API void CHESS_CALL trainer_destroy(TrainerHandle t) { + delete t; /* delete nullptr is safe */ +} + } /* extern "C" */ diff --git a/native/chess_engine/src/position.h b/native/chess_engine/src/position.h index 8976394..f615ffb 100644 --- a/native/chess_engine/src/position.h +++ b/native/chess_engine/src/position.h @@ -47,6 +47,22 @@ public: void do_move(Move m); void undo_move(Move m); + // Legal moves for a SPECIFIED color (for mobility eval of either side). When c is not + // the side to move, temporarily flips side-to-move (and clears the en-passant square, + // which belongs to the other side) so generate_legal runs for c, then restores. The + // Zobrist key is untouched and unused by move generation, so this leaves the position + // observably unchanged. + void generate_legal_for(Color c, MoveList& list) { + if (sideToMove == c) { generate_legal(list); return; } + Color savedSide = sideToMove; + Square savedEp = epSquare; + sideToMove = c; + epSquare = SQ_NONE; + generate_legal(list); + sideToMove = savedSide; + epSquare = savedEp; + } + // Seed prior-position keys (oldest first, excluding the current position) so // is_draw() can see game history the FEN doesn't carry. Call once, right after // from_fen and before any do_move.