Gitea/workflows #1
@@ -1,6 +1,9 @@
|
|||||||
using JoshHeaps.Net.Models;
|
using JoshHeaps.Net.Hubs;
|
||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
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 System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Controllers;
|
namespace JoshHeaps.Net.Controllers;
|
||||||
@@ -11,7 +14,8 @@ public class ChessController(
|
|||||||
IChessService chessService,
|
IChessService chessService,
|
||||||
IBackgroundTaskQueue queue,
|
IBackgroundTaskQueue queue,
|
||||||
IChessEngineFactory engineFactory,
|
IChessEngineFactory engineFactory,
|
||||||
IComputerMoveOrchestrator orchestrator) : ControllerBase
|
IComputerMoveOrchestrator orchestrator,
|
||||||
|
IHubContext<ChessHub> chessHub) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Store of ongoing games.
|
/// Store of ongoing games.
|
||||||
@@ -23,6 +27,8 @@ public class ChessController(
|
|||||||
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);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new chess game and store it in-memory.
|
/// Create a new chess game and store it in-memory.
|
||||||
@@ -70,6 +76,31 @@ public class ChessController(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("watch/cpu")]
|
||||||
|
[HttpGet("watch/cpu/{difficulty}")]
|
||||||
|
public ActionResult CreateSelfPlayGame(int difficulty = 4)
|
||||||
|
{
|
||||||
|
var gameState = chessService.CreateNewGame();
|
||||||
|
_games[gameState.GameId] = gameState;
|
||||||
|
|
||||||
|
gameState.IsVsComputer = true;
|
||||||
|
gameState.IsComputerVsComputer = true;
|
||||||
|
gameState.WhiteJoined = true;
|
||||||
|
gameState.BlackJoined = true;
|
||||||
|
gameState.WhitePlayerId = Guid.NewGuid();
|
||||||
|
gameState.BlackPlayerId = Guid.NewGuid();
|
||||||
|
gameState.Computer = engineFactory.Create(difficulty);
|
||||||
|
|
||||||
|
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||||
|
StartSelfPlay(gameState);
|
||||||
|
|
||||||
|
return Ok(new { gameState.GameId });
|
||||||
|
}
|
||||||
|
|
||||||
/// <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
|
||||||
@@ -112,6 +143,30 @@ public class ChessController(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List in-progress games for spectators: both sides present and the game not yet decided.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("active")]
|
||||||
|
public ActionResult GetActiveGames()
|
||||||
|
{
|
||||||
|
var activeGames = _games.Values
|
||||||
|
// In-progress games, plus finished computer-vs-computer games still in their result window.
|
||||||
|
.Where(g => g.WhiteJoined && g.BlackJoined
|
||||||
|
&& ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer))
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
g.GameId,
|
||||||
|
g.IsVsComputer,
|
||||||
|
g.IsComputerVsComputer,
|
||||||
|
CurrentPlayer = g.CurrentPlayer.ToString(),
|
||||||
|
MoveCount = g.MoveHistory.Count,
|
||||||
|
g.IsCheck
|
||||||
|
})
|
||||||
|
.OrderByDescending(g => g.MoveCount);
|
||||||
|
|
||||||
|
return Ok(activeGames);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get the state of an existing game by ID.
|
/// Get the state of an existing game by ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -128,6 +183,7 @@ public class ChessController(
|
|||||||
gameState.IsCheck,
|
gameState.IsCheck,
|
||||||
gameState.IsCheckmate,
|
gameState.IsCheckmate,
|
||||||
gameState.IsStalemate,
|
gameState.IsStalemate,
|
||||||
|
gameState.IsThreefoldRepetition,
|
||||||
EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null,
|
EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null,
|
||||||
gameState.WhiteCanCastleKingside,
|
gameState.WhiteCanCastleKingside,
|
||||||
gameState.WhiteCanCastleQueenside,
|
gameState.WhiteCanCastleQueenside,
|
||||||
@@ -180,19 +236,63 @@ public class ChessController(
|
|||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
return BadRequest(result);
|
return BadRequest(result);
|
||||||
|
|
||||||
if (result.IsCheckmate || result.IsStalemate)
|
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
|
||||||
|
|
||||||
|
if (isGameOver)
|
||||||
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
||||||
else if (gameState.IsVsComputer)
|
else if (gameState.IsVsComputer)
|
||||||
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||||
else
|
else
|
||||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
||||||
|
|
||||||
if (gameState.IsVsComputer && gameState.Computer is not null)
|
if (!isGameOver && gameState.IsVsComputer && gameState.Computer is not null)
|
||||||
queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!));
|
queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!));
|
||||||
|
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Forfeit a game on behalf of the calling player, handing the win to the opponent.
|
||||||
|
/// Used when a player abandons a game (e.g. starts a new one mid-game).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("forfeit")]
|
||||||
|
public async Task<ActionResult> Forfeit([FromBody] ForfeitDto forfeit)
|
||||||
|
{
|
||||||
|
if (!_games.TryGetValue(forfeit.GameId, out var gameState))
|
||||||
|
return NotFound("Game not found");
|
||||||
|
|
||||||
|
if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited)
|
||||||
|
return Ok();
|
||||||
|
|
||||||
|
var isWhitePlayer = gameState.WhitePlayerId == forfeit.PlayerId;
|
||||||
|
var isBlackPlayer = gameState.BlackPlayerId == forfeit.PlayerId;
|
||||||
|
|
||||||
|
if (!isWhitePlayer && !isBlackPlayer)
|
||||||
|
return StatusCode(403, "You are not a player in this game.");
|
||||||
|
|
||||||
|
gameState.IsForfeited = true;
|
||||||
|
gameState.Winner = isWhitePlayer ? PieceColor.Black : PieceColor.White;
|
||||||
|
|
||||||
|
await chessHub.Clients.Group(gameState.GameId.ToString())
|
||||||
|
.SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit");
|
||||||
|
|
||||||
|
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Export a game as PGN (works while the game is still in memory after it ends).
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{gameId}/pgn")]
|
||||||
|
public ActionResult GetPgn(Guid gameId)
|
||||||
|
{
|
||||||
|
if (!_games.TryGetValue(gameId, out var gameState))
|
||||||
|
return NotFound("Game not found");
|
||||||
|
|
||||||
|
return Content(gameState.ToPgn(), "application/x-chess-pgn");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get the legal moves for a specific piece in a specific game.
|
/// Get the legal moves for a specific piece in a specific game.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -226,6 +326,42 @@ public class ChessController(
|
|||||||
return Ok(allMoves);
|
return Ok(allMoves);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </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));
|
||||||
|
|
||||||
|
while (_games.ContainsKey(gameState.GameId)
|
||||||
|
&& !gameState.IsCheckmate
|
||||||
|
&& !gameState.IsStalemate
|
||||||
|
&& !gameState.IsThreefoldRepetition
|
||||||
|
&& !gameState.IsForfeited)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await orchestrator.PlayAsync(gameState, gameState.Computer!);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(_selfPlayMoveDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the finished game in place briefly so spectators can see the result.
|
||||||
|
if (_games.ContainsKey(gameState.GameId))
|
||||||
|
ScheduleRemoveGame(gameState.GameId, _selfPlayResultTimeout);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
|
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
|
||||||
{
|
{
|
||||||
if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts))
|
if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts))
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace JoshHeaps.Net.Models;
|
||||||
|
|
||||||
|
public class ForfeitDto
|
||||||
|
{
|
||||||
|
public Guid GameId { get; set; }
|
||||||
|
public Guid PlayerId { get; set; }
|
||||||
|
}
|
||||||
@@ -28,10 +28,25 @@ public class GameState
|
|||||||
public bool IsCheck { get; set; }
|
public bool IsCheck { get; set; }
|
||||||
public bool IsCheckmate { get; set; }
|
public bool IsCheckmate { get; set; }
|
||||||
public bool IsStalemate { get; set; }
|
public bool IsStalemate { get; set; }
|
||||||
|
public bool IsThreefoldRepetition { get; set; }
|
||||||
|
public bool IsForfeited { get; set; } = false;
|
||||||
|
|
||||||
|
// The color that won, when the game ended by forfeit (null while the game is live).
|
||||||
|
public PieceColor? Winner { get; set; }
|
||||||
|
|
||||||
// Keep a history of moves if desired
|
// Keep a history of moves if desired
|
||||||
public List<string> MoveHistory { get; set; }
|
public List<string> MoveHistory { get; set; }
|
||||||
|
|
||||||
|
// Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection.
|
||||||
|
public List<string> PositionHistory { get; set; }
|
||||||
|
|
||||||
|
// Moves in standard algebraic notation (SAN), in order, for PGN export.
|
||||||
|
public List<string> SanHistory { get; set; }
|
||||||
|
|
||||||
|
// Half-moves since the last capture or pawn move (the FEN 50-move clock). Also tells
|
||||||
|
// us how many trailing PositionHistory entries belong to the current repetition window.
|
||||||
|
public int HalfmoveClock { get; set; }
|
||||||
|
|
||||||
// A list of all pieces to quickly reference them (optional but convenient).
|
// A list of all pieces to quickly reference them (optional but convenient).
|
||||||
// Alternatively, you can iterate the Board array.
|
// Alternatively, you can iterate the Board array.
|
||||||
public List<ChessPiece> Pieces { get; set; }
|
public List<ChessPiece> Pieces { get; set; }
|
||||||
@@ -42,6 +57,7 @@ public class GameState
|
|||||||
public Guid BlackPlayerId { get; set; }
|
public Guid BlackPlayerId { get; set; }
|
||||||
|
|
||||||
public bool IsVsComputer { get; set; } = false;
|
public bool IsVsComputer { get; set; } = false;
|
||||||
|
public bool IsComputerVsComputer { get; set; } = false;
|
||||||
|
|
||||||
public IChessEngine? Computer { get; set; }
|
public IChessEngine? Computer { get; set; }
|
||||||
|
|
||||||
@@ -54,5 +70,7 @@ public class GameState
|
|||||||
Board = new ChessPiece[8, 8];
|
Board = new ChessPiece[8, 8];
|
||||||
Pieces = [];
|
Pieces = [];
|
||||||
MoveHistory = [];
|
MoveHistory = [];
|
||||||
|
PositionHistory = [];
|
||||||
|
SanHistory = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ public class MoveResultDto
|
|||||||
public bool IsCheck { get; set; }
|
public bool IsCheck { get; set; }
|
||||||
public bool IsCheckmate { get; set; }
|
public bool IsCheckmate { get; set; }
|
||||||
public bool IsStalemate { get; set; }
|
public bool IsStalemate { get; set; }
|
||||||
|
public bool IsThreefoldRepetition { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
<div id="buttonContainer" class="sideContent">
|
<div id="buttonContainer" class="sideContent">
|
||||||
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
|
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
|
||||||
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button>
|
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button>
|
||||||
|
<button id="copyPgnBtn" onclick="copyPgn()" style="display: none">Copy PGN</button>
|
||||||
|
<a id="watchLink" href="/watch">Watch other games →</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
<h2 class="section-title">Demos</h2>
|
<h2 class="section-title">Demos</h2>
|
||||||
<div id="buttonWrapper">
|
<div id="buttonWrapper">
|
||||||
<button class="demoButton" onclick="window.location.href='/chess'">Play Chess</button>
|
<button class="demoButton" onclick="window.location.href='/chess'">Play Chess</button>
|
||||||
|
<button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button>
|
||||||
<button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
|
<button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
|
||||||
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
|
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
|
||||||
<button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
|
<button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
@page
|
||||||
|
@model JoshHeaps.Net.Pages.WatchModel
|
||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
ViewData["Title"] = "Watch Chess";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div id="watchHeader">
|
||||||
|
<h1>Live Chess</h1>
|
||||||
|
<p id="watchStatus">Loading games…</p>
|
||||||
|
<div id="watchControls">
|
||||||
|
<label for="cpuDifficulty">Difficulty</label>
|
||||||
|
<select id="cpuDifficulty">
|
||||||
|
@for (int i = 1; i <= 20; i++)
|
||||||
|
{
|
||||||
|
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
|
||||||
|
</div>
|
||||||
|
<a id="backToPlay" href="/chess">← Play a game</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="gamesFeed"></div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script src="~/js/signalr/signalr.min.js"></script>
|
||||||
|
<script src="~/js/ChessScripts/Spectate.js"></script>
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
|
||||||
|
<link rel="stylesheet" href="~/css/chess/spectate.css?v=@ViewData["cssVersion"]" />
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Pages
|
||||||
|
{
|
||||||
|
public class WatchModel : PageModel
|
||||||
|
{
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -85,11 +85,34 @@ public static class ChessEngineHelpers
|
|||||||
|
|
||||||
/* 5-6) half-move clock + full-move number */
|
/* 5-6) half-move clock + full-move number */
|
||||||
int fullMoves = gs.MoveHistory.Count / 2 + 1;
|
int fullMoves = gs.MoveHistory.Count / 2 + 1;
|
||||||
sb.Append(" 0 ").Append(fullMoves);
|
sb.Append(' ').Append(gs.HalfmoveClock).Append(' ').Append(fullMoves);
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The prior positions since the last irreversible move (capture/pawn move), as
|
||||||
|
/// completed FEN strings, oldest first and excluding the current position. This is
|
||||||
|
/// the repetition window the engine needs to detect threefold/50-move draws that a
|
||||||
|
/// single FEN can't express.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string> RepetitionHistory(this GameState gs)
|
||||||
|
{
|
||||||
|
int clock = gs.HalfmoveClock;
|
||||||
|
int count = gs.PositionHistory.Count;
|
||||||
|
int start = count - 1 - clock; // PositionHistory ends with the current position
|
||||||
|
|
||||||
|
if (clock <= 0 || start < 0)
|
||||||
|
return Array.Empty<string>();
|
||||||
|
|
||||||
|
var fens = new List<string>(clock);
|
||||||
|
|
||||||
|
for (int i = start; i < count - 1; i++)
|
||||||
|
fens.Add(gs.PositionHistory[i] + " 0 1"); // complete the 4-field key into a parseable FEN
|
||||||
|
|
||||||
|
return fens;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- helpers ---------- */
|
/* ---------- helpers ---------- */
|
||||||
|
|
||||||
private static string GetCastlingFlags(GameState gs)
|
private static string GetCastlingFlags(GameState gs)
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ public class ChessService : IChessService
|
|||||||
|
|
||||||
gameState.Pieces.Clear();
|
gameState.Pieces.Clear();
|
||||||
gameState.MoveHistory.Clear();
|
gameState.MoveHistory.Clear();
|
||||||
|
gameState.SanHistory.Clear();
|
||||||
|
|
||||||
gameState.WhiteCanCastleKingside = true;
|
gameState.WhiteCanCastleKingside = true;
|
||||||
gameState.WhiteCanCastleQueenside = true;
|
gameState.WhiteCanCastleQueenside = true;
|
||||||
gameState.BlackCanCastleKingside = true;
|
gameState.BlackCanCastleKingside = true;
|
||||||
gameState.BlackCanCastleQueenside = true;
|
gameState.BlackCanCastleQueenside = true;
|
||||||
gameState.EnPassantTarget = null;
|
gameState.EnPassantTarget = null;
|
||||||
|
gameState.HalfmoveClock = 0;
|
||||||
|
|
||||||
SetupBlackPieces(gameState);
|
SetupBlackPieces(gameState);
|
||||||
SetupWhitePieces(gameState);
|
SetupWhitePieces(gameState);
|
||||||
@@ -35,6 +37,9 @@ public class ChessService : IChessService
|
|||||||
gameState.CurrentPlayer = PieceColor.White;
|
gameState.CurrentPlayer = PieceColor.White;
|
||||||
|
|
||||||
UpdateCheckStatus(gameState);
|
UpdateCheckStatus(gameState);
|
||||||
|
|
||||||
|
gameState.PositionHistory.Clear();
|
||||||
|
gameState.PositionHistory.Add(PositionKey(gameState));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SetupBlackPieces(GameState gs)
|
private static void SetupBlackPieces(GameState gs)
|
||||||
@@ -137,12 +142,23 @@ public class ChessService : IChessService
|
|||||||
if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol))
|
if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol))
|
||||||
return new MoveResultDto { Success = false, Message = "Illegal move." };
|
return new MoveResultDto { Success = false, Message = "Illegal move." };
|
||||||
|
|
||||||
|
// SAN is built before the move (needs the pre-move board for captures/disambiguation);
|
||||||
|
// the check/mate suffix is appended after UpdateCheckStatus.
|
||||||
|
var sanBase = BuildSan(gameState, piece, targetPos, moveDto);
|
||||||
|
|
||||||
PerformMove(gameState, piece, targetPos, moveDto);
|
PerformMove(gameState, piece, targetPos, moveDto);
|
||||||
|
|
||||||
UpdateCheckStatus(gameState);
|
UpdateCheckStatus(gameState);
|
||||||
|
|
||||||
var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
|
var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
|
||||||
gameState.MoveHistory.Add(notation);
|
gameState.MoveHistory.Add(notation);
|
||||||
|
gameState.SanHistory.Add(sanBase + (gameState.IsCheckmate ? "#" : gameState.IsCheck ? "+" : ""));
|
||||||
|
|
||||||
|
var positionKey = PositionKey(gameState);
|
||||||
|
gameState.PositionHistory.Add(positionKey);
|
||||||
|
|
||||||
|
if (gameState.PositionHistory.Count(k => k == positionKey) >= 3)
|
||||||
|
gameState.IsThreefoldRepetition = true;
|
||||||
|
|
||||||
return new MoveResultDto
|
return new MoveResultDto
|
||||||
{
|
{
|
||||||
@@ -150,14 +166,95 @@ public class ChessService : IChessService
|
|||||||
Message = "Move successful.",
|
Message = "Move successful.",
|
||||||
IsCheck = gameState.IsCheck,
|
IsCheck = gameState.IsCheck,
|
||||||
IsCheckmate = gameState.IsCheckmate,
|
IsCheckmate = gameState.IsCheckmate,
|
||||||
IsStalemate = gameState.IsStalemate
|
IsStalemate = gameState.IsStalemate,
|
||||||
|
IsThreefoldRepetition = gameState.IsThreefoldRepetition
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the standard-algebraic notation for a move from the position BEFORE it is
|
||||||
|
/// applied (the check/mate suffix is added by the caller afterward).
|
||||||
|
/// </summary>
|
||||||
|
private string BuildSan(GameState gs, ChessPiece piece, Position target, MoveDto moveDto)
|
||||||
|
{
|
||||||
|
var from = piece.Position;
|
||||||
|
|
||||||
|
if (piece.Type == PieceType.King && Math.Abs(target.Col - from.Col) == 2)
|
||||||
|
return target.Col > from.Col ? "O-O" : "O-O-O";
|
||||||
|
|
||||||
|
bool targetOccupied = gs.Board[target.Row, target.Col] != null;
|
||||||
|
bool isEnPassant = piece.Type == PieceType.Pawn && from.Col != target.Col && !targetOccupied;
|
||||||
|
bool isCapture = targetOccupied || isEnPassant;
|
||||||
|
string dest = SquareName(target);
|
||||||
|
|
||||||
|
if (piece.Type == PieceType.Pawn)
|
||||||
|
{
|
||||||
|
var san = isCapture ? $"{FileChar(from.Col)}x{dest}" : dest;
|
||||||
|
|
||||||
|
bool promotes = (piece.Color == PieceColor.White && target.Row == 0)
|
||||||
|
|| (piece.Color == PieceColor.Black && target.Row == 7);
|
||||||
|
|
||||||
|
if (promotes)
|
||||||
|
san += "=" + PieceLetter(moveDto.PromotionChoice ?? PieceType.Queen);
|
||||||
|
|
||||||
|
return san;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{PieceLetter(piece.Type)}{Disambiguation(gs, piece, target)}{(isCapture ? "x" : "")}{dest}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SAN disambiguation: when another piece of the same type and color can also reach the
|
||||||
|
/// target, qualify the origin by file, else rank, else both.
|
||||||
|
/// </summary>
|
||||||
|
private string Disambiguation(GameState gs, ChessPiece piece, Position target)
|
||||||
|
{
|
||||||
|
var rivals = gs.Pieces
|
||||||
|
.Where(p => p.Id != piece.Id && p.Type == piece.Type && p.Color == piece.Color && p.Position.Row >= 0)
|
||||||
|
.Where(p => GetLegalMovesForPiece(gs, p.Id).Any(m => m.Row == target.Row && m.Col == target.Col))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (rivals.Count == 0)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
if (rivals.All(p => p.Position.Col != piece.Position.Col))
|
||||||
|
return FileChar(piece.Position.Col).ToString();
|
||||||
|
|
||||||
|
if (rivals.All(p => p.Position.Row != piece.Position.Row))
|
||||||
|
return (8 - piece.Position.Row).ToString();
|
||||||
|
|
||||||
|
return $"{FileChar(piece.Position.Col)}{8 - piece.Position.Row}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static char FileChar(int col) => (char)('a' + col);
|
||||||
|
|
||||||
|
private static string SquareName(Position p) => $"{FileChar(p.Col)}{8 - p.Row}";
|
||||||
|
|
||||||
|
private static string PieceLetter(PieceType type) => type switch
|
||||||
|
{
|
||||||
|
PieceType.Knight => "N",
|
||||||
|
PieceType.Bishop => "B",
|
||||||
|
PieceType.Rook => "R",
|
||||||
|
PieceType.Queen => "Q",
|
||||||
|
PieceType.King => "K",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The repetition signature of a position: the first four FEN fields — piece placement,
|
||||||
|
/// side to move, castling rights, and en-passant target. Move counters are excluded.
|
||||||
|
/// </summary>
|
||||||
|
private static string PositionKey(GameState gs)
|
||||||
|
{
|
||||||
|
var fields = gs.ToFen().Split(' ');
|
||||||
|
return string.Join(' ', fields.Take(4));
|
||||||
|
}
|
||||||
|
|
||||||
private static void PerformMove(GameState gs, ChessPiece piece, Position targetPos, MoveDto moveDto)
|
private static void PerformMove(GameState gs, ChessPiece piece, Position targetPos, MoveDto moveDto)
|
||||||
{
|
{
|
||||||
var oldPos = piece.Position;
|
var oldPos = piece.Position;
|
||||||
var captured = gs.Board[targetPos.Row, targetPos.Col];
|
var captured = gs.Board[targetPos.Row, targetPos.Col];
|
||||||
|
var isPawnMove = piece.Type == PieceType.Pawn; // captured before promotion can change Type
|
||||||
|
|
||||||
HandleEnPassantIfNeeded(gs, piece, targetPos, ref captured);
|
HandleEnPassantIfNeeded(gs, piece, targetPos, ref captured);
|
||||||
|
|
||||||
@@ -179,6 +276,9 @@ public class ChessService : IChessService
|
|||||||
|
|
||||||
UpdateCastlingRights(gs, piece, oldPos);
|
UpdateCastlingRights(gs, piece, oldPos);
|
||||||
|
|
||||||
|
// Reset the 50-move clock on captures and pawn moves (irreversible); else advance it.
|
||||||
|
gs.HalfmoveClock = (isPawnMove || captured != null) ? 0 : gs.HalfmoveClock + 1;
|
||||||
|
|
||||||
gs.CurrentPlayer = gs.CurrentPlayer == PieceColor.White
|
gs.CurrentPlayer = gs.CurrentPlayer == PieceColor.White
|
||||||
? PieceColor.Black
|
? PieceColor.Black
|
||||||
: PieceColor.White;
|
: PieceColor.White;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public sealed class ComputerMoveOrchestrator(
|
|||||||
{
|
{
|
||||||
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine)
|
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine)
|
||||||
{
|
{
|
||||||
var uci = await engine.GetBestMoveAsync(state.ToFen());
|
var uci = await engine.GetBestMoveAsync(state.ToFen(), state.RepetitionHistory());
|
||||||
|
|
||||||
var move = uci.ToMoveDto(
|
var move = uci.ToMoveDto(
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ public sealed partial class CustomChessEngine : IChessEngine
|
|||||||
_handle = new EngineSafeHandle(handle);
|
_handle = new EngineSafeHandle(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<string> GetBestMoveAsync(string fen) => Task.Run(() => GetBestMove(fen));
|
public Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens) =>
|
||||||
|
Task.Run(() => GetBestMove(fen, string.Join('\n', historyFens)));
|
||||||
|
|
||||||
private unsafe string GetBestMove(string fen)
|
private unsafe string GetBestMove(string fen, string history)
|
||||||
{
|
{
|
||||||
const int bufferLength = 16; // longest UCI move is 5 chars ("e7e8q") + NUL
|
const int bufferLength = 16; // longest UCI move is 5 chars ("e7e8q") + NUL
|
||||||
byte* buffer = stackalloc byte[bufferLength];
|
byte* buffer = stackalloc byte[bufferLength];
|
||||||
@@ -40,7 +41,7 @@ public sealed partial class CustomChessEngine : IChessEngine
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_handle.DangerousAddRef(ref added);
|
_handle.DangerousAddRef(ref added);
|
||||||
var code = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, buffer, bufferLength);
|
var code = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, history, buffer, bufferLength);
|
||||||
|
|
||||||
if (code != 0)
|
if (code != 0)
|
||||||
throw new InvalidOperationException($"Native chess engine failed to produce a move for FEN '{fen}' (engine_best_move returned {code}).");
|
throw new InvalidOperationException($"Native chess engine failed to produce a move for FEN '{fen}' (engine_best_move returned {code}).");
|
||||||
@@ -109,7 +110,7 @@ public sealed partial class CustomChessEngine : IChessEngine
|
|||||||
|
|
||||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||||
internal static unsafe partial int engine_best_move(IntPtr engine, string fen, byte* outBuffer, int outLength);
|
internal static unsafe partial int engine_best_move(IntPtr engine, string fen, string history, byte* outBuffer, int outLength);
|
||||||
|
|
||||||
[LibraryImport(LibName)]
|
[LibraryImport(LibName)]
|
||||||
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using JoshHeaps.Net.Models;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
|
public static class PgnExporter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Render a game as PGN: the standard seven-tag header plus the SAN movetext and result.
|
||||||
|
/// </summary>
|
||||||
|
public static string ToPgn(this GameState gs)
|
||||||
|
{
|
||||||
|
var result = ResultTag(gs);
|
||||||
|
var name = gs.IsComputerVsComputer ? "Computer" : null;
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("[Event \"JoshHeaps.Net Chess\"]");
|
||||||
|
sb.AppendLine("[Site \"joshheaps.net\"]");
|
||||||
|
sb.AppendLine($"[Date \"{DateTime.Now:yyyy.MM.dd}\"]");
|
||||||
|
sb.AppendLine($"[White \"{name ?? "White"}\"]");
|
||||||
|
sb.AppendLine($"[Black \"{name ?? "Black"}\"]");
|
||||||
|
sb.AppendLine($"[Result \"{result}\"]");
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
for (int i = 0; i < gs.SanHistory.Count; i++)
|
||||||
|
{
|
||||||
|
if (i % 2 == 0)
|
||||||
|
sb.Append(i / 2 + 1).Append(". ");
|
||||||
|
|
||||||
|
sb.Append(gs.SanHistory[i]).Append(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(result);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResultTag(GameState gs)
|
||||||
|
{
|
||||||
|
if (gs.IsCheckmate)
|
||||||
|
return gs.CurrentPlayer == PieceColor.White ? "0-1" : "1-0";
|
||||||
|
|
||||||
|
if (gs.IsStalemate || gs.IsThreefoldRepetition)
|
||||||
|
return "1/2-1/2";
|
||||||
|
|
||||||
|
if (gs.IsForfeited)
|
||||||
|
return gs.Winner == PieceColor.White ? "1-0" : "0-1";
|
||||||
|
|
||||||
|
return "*";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,8 +74,9 @@ public sealed class Stockfish : IChessEngine
|
|||||||
WaitFor("readyok").GetAwaiter().GetResult();
|
WaitFor("readyok").GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GetBestMoveAsync(string fen)
|
public async Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens)
|
||||||
{
|
{
|
||||||
|
// historyFens is unused: Stockfish tracks repetition from the position it's given.
|
||||||
Send($"position fen {fen}");
|
Send($"position fen {fen}");
|
||||||
Send($"go depth {_skill}");
|
Send($"go depth {_skill}");
|
||||||
string? best = null;
|
string? best = null;
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ public interface IChessEngine : IAsyncDisposable
|
|||||||
/// Returns the engine's chosen move in UCI long-algebraic form (e.g. "e2e4", "e7e8q").
|
/// Returns the engine's chosen move in UCI long-algebraic form (e.g. "e2e4", "e7e8q").
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="fen">The current position as a FEN string.</param>
|
/// <param name="fen">The current position as a FEN string.</param>
|
||||||
|
/// <param name="historyFens">
|
||||||
|
/// Prior positions since the last irreversible move, oldest first, excluding the
|
||||||
|
/// current one — lets the engine detect threefold/50-move draws the FEN can't carry.
|
||||||
|
/// May be empty.
|
||||||
|
/// </param>
|
||||||
/// <returns>The selected move as a UCI string.</returns>
|
/// <returns>The selected move as a UCI string.</returns>
|
||||||
Task<string> GetBestMoveAsync(string fen);
|
Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,28 @@
|
|||||||
margin: 2vh;
|
margin: 2vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#copyPgnBtn {
|
||||||
|
background-color: #8cd5ed;
|
||||||
|
color: #262626;
|
||||||
|
border-radius: 30px;
|
||||||
|
border: 0px;
|
||||||
|
cursor: pointer;
|
||||||
|
order: 1;
|
||||||
|
padding: 2vh;
|
||||||
|
margin: 2vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#watchLink {
|
||||||
|
color: #8cd5ed;
|
||||||
|
text-decoration: none;
|
||||||
|
margin: 2vh;
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#watchLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
#chessContainer {
|
#chessContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
html, body {
|
||||||
|
background-color: #2b2c30;
|
||||||
|
color: #d6d6d6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#watchHeader {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#watchHeader h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#watchStatus {
|
||||||
|
color: #9a9a9a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#watchControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cpuDifficulty {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #d6d6d6;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#startCpuVsCpu {
|
||||||
|
background-color: #8cd5ed;
|
||||||
|
color: #262626;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#startCpuVsCpu:hover {
|
||||||
|
background-color: #a5e0f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#backToPlay {
|
||||||
|
color: #8cd5ed;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#backToPlay:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gamesFeed {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameCard {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 0, 0, 0.4);
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameResult {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #8cd5ed;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyPgnBtn {
|
||||||
|
display: block;
|
||||||
|
margin: 0.75rem auto 0;
|
||||||
|
background-color: #8cd5ed;
|
||||||
|
color: #262626;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyPgnBtn:hover {
|
||||||
|
background-color: #a5e0f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.fullscreen-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameCard.fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
cursor: default;
|
||||||
|
background-color: #2b2c30;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameCard.fullscreen .miniBoard {
|
||||||
|
width: min(90vh, 90vw);
|
||||||
|
height: min(90vh, 90vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameCard.fullscreen .backButton {
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
background-color: #8cd5ed;
|
||||||
|
color: #262626;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameCardHeader {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniBoard {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
grid-template-rows: repeat(8, 1fr);
|
||||||
|
}
|
||||||
@@ -14,6 +14,20 @@ const ChessAPI = {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getPgn(gameId) {
|
||||||
|
const response = await fetch(`/api/chess/${gameId}/pgn`);
|
||||||
|
if (!response.ok) throw new Error("PGN unavailable");
|
||||||
|
return await response.text();
|
||||||
|
},
|
||||||
|
|
||||||
|
async forfeit(gameId, playerId) {
|
||||||
|
await fetch("/api/chess/forfeit", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ GameId: gameId, PlayerId: playerId })
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async getLegalMoves(pieceId) {
|
async getLegalMoves(pieceId) {
|
||||||
const response = await fetch(`/api/chess/${GameState.currentGameId}/legalMoves/${pieceId}`);
|
const response = await fetch(`/api/chess/${GameState.currentGameId}/legalMoves/${pieceId}`);
|
||||||
if (!response.ok) throw new Error("API failed");
|
if (!response.ok) throw new Error("API failed");
|
||||||
@@ -101,9 +115,13 @@ const ChessAPI = {
|
|||||||
} else if (moveResult.isStalemate) {
|
} else if (moveResult.isStalemate) {
|
||||||
alert("🤝 Stalemate!");
|
alert("🤝 Stalemate!");
|
||||||
gameOver = true;
|
gameOver = true;
|
||||||
|
} else if (moveResult.isThreefoldRepetition) {
|
||||||
|
alert("🤝 Draw by threefold repetition!");
|
||||||
|
gameOver = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gameOver) {
|
if (gameOver) {
|
||||||
|
showCopyPgn(GameState.currentGameId);
|
||||||
await ChessSignalR.leaveGame();
|
await ChessSignalR.leaveGame();
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const ChessSignalR = {
|
|||||||
await this.handleMoveUpdate(gameId, moveDto, moveResultDto);
|
await this.handleMoveUpdate(gameId, moveDto, moveResultDto);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => {
|
||||||
|
await this.handleGameOver(gameId, winner, reason);
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.connection.start();
|
await this.connection.start();
|
||||||
console.log("✅ SignalR connected");
|
console.log("✅ SignalR connected");
|
||||||
@@ -34,6 +38,18 @@ const ChessSignalR = {
|
|||||||
ChessAPI.alertGameStatusChange(moveResultDto);
|
ChessAPI.alertGameStatusChange(moveResultDto);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async handleGameOver(gameId, winner, reason) {
|
||||||
|
if (gameId !== GameState.currentGameId) return;
|
||||||
|
|
||||||
|
const youWon = (winner === "White") === GameState.currentPlayerIsWhite;
|
||||||
|
|
||||||
|
if (reason === "forfeit")
|
||||||
|
alert(youWon ? "🏳️ Your opponent forfeited — you win!" : "🏳️ You forfeited this game.");
|
||||||
|
|
||||||
|
showCopyPgn(gameId);
|
||||||
|
await this.leaveGame();
|
||||||
|
},
|
||||||
|
|
||||||
async notifyMoveMade(moveDto, moveResult) {
|
async notifyMoveMade(moveDto, moveResult) {
|
||||||
if (this.connection) {
|
if (this.connection) {
|
||||||
await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult);
|
await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult);
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
const Spectate = {
|
||||||
|
connection: null,
|
||||||
|
games: new Map(), // gameId -> { isVsComputer, isComputerVsComputer, result }
|
||||||
|
pgns: new Map(), // gameId -> PGN text (prefetched when a game finishes)
|
||||||
|
|
||||||
|
pieceTypeNames: ["Pawn", "Rook", "Knight", "Bishop", "Queen", "King"],
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.connection = new signalR.HubConnectionBuilder()
|
||||||
|
.withUrl("/chessHub")
|
||||||
|
.configureLogging(signalR.LogLevel.Warning)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto) =>
|
||||||
|
this.handleMoveUpdate(gameId, moveDto));
|
||||||
|
|
||||||
|
this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.connection.start();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ SignalR failed to start:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refreshGames();
|
||||||
|
setInterval(() => this.refreshGames(), 5000);
|
||||||
|
},
|
||||||
|
|
||||||
|
async startCpuGame() {
|
||||||
|
const difficulty = document.getElementById("cpuDifficulty").value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/chess/watch/cpu/${difficulty}`);
|
||||||
|
await this.refreshGames();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Could not start CPU vs CPU game.", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshGames() {
|
||||||
|
let games;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/chess/active");
|
||||||
|
games = await response.json();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIds = new Set(games.map(g => g.gameId));
|
||||||
|
|
||||||
|
for (const gameId of [...this.games.keys()])
|
||||||
|
if (!activeIds.has(gameId))
|
||||||
|
await this.removeGame(gameId);
|
||||||
|
|
||||||
|
for (const game of games)
|
||||||
|
if (this.games.has(game.gameId))
|
||||||
|
this.updateHeader(game.gameId, game.currentPlayer, game.moveCount, game.isCheck);
|
||||||
|
else
|
||||||
|
await this.addGame(game);
|
||||||
|
|
||||||
|
const status = document.getElementById("watchStatus");
|
||||||
|
status.textContent = games.length === 0
|
||||||
|
? "No games are being played right now."
|
||||||
|
: `${games.length} game${games.length === 1 ? "" : "s"} in progress`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async addGame(game) {
|
||||||
|
this.games.set(game.gameId, {
|
||||||
|
isVsComputer: game.isVsComputer,
|
||||||
|
isComputerVsComputer: game.isComputerVsComputer
|
||||||
|
});
|
||||||
|
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "gameCard";
|
||||||
|
card.id = `card-${game.gameId}`;
|
||||||
|
card.onclick = () => this.enterFullscreen(game.gameId);
|
||||||
|
|
||||||
|
const back = document.createElement("button");
|
||||||
|
back.className = "backButton";
|
||||||
|
back.textContent = "← Back";
|
||||||
|
back.onclick = (event) => this.exitFullscreen(game.gameId, event);
|
||||||
|
card.appendChild(back);
|
||||||
|
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "gameCardHeader";
|
||||||
|
header.id = `header-${game.gameId}`;
|
||||||
|
header.textContent = this.headerText(this.games.get(game.gameId), game.currentPlayer, game.moveCount, game.isCheck);
|
||||||
|
card.appendChild(header);
|
||||||
|
|
||||||
|
const board = document.createElement("div");
|
||||||
|
board.className = "miniBoard";
|
||||||
|
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
const square = document.createElement("div");
|
||||||
|
square.id = `sq-${game.gameId}-${i}`;
|
||||||
|
square.className = `chessSquare ${(i + Math.floor(i / 8)) % 2 === 0 ? "light" : "dark"}`;
|
||||||
|
board.appendChild(square);
|
||||||
|
}
|
||||||
|
|
||||||
|
card.appendChild(board);
|
||||||
|
document.getElementById("gamesFeed").appendChild(card);
|
||||||
|
|
||||||
|
await this.connection.invoke("JoinWebsocketGroup", game.gameId).catch(() => { });
|
||||||
|
await this.renderGame(game.gameId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeGame(gameId) {
|
||||||
|
this.games.delete(gameId);
|
||||||
|
this.pgns.delete(gameId);
|
||||||
|
document.getElementById(`card-${gameId}`)?.remove();
|
||||||
|
await this.connection.invoke("LeaveWebsocketGroup", gameId).catch(() => { });
|
||||||
|
},
|
||||||
|
|
||||||
|
async renderGame(gameId) {
|
||||||
|
const response = await fetch(`/api/chess/${gameId}`);
|
||||||
|
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const state = await response.json();
|
||||||
|
const result = this.resultTextFromState(state);
|
||||||
|
const stored = this.games.get(gameId);
|
||||||
|
|
||||||
|
if (stored) stored.result = result;
|
||||||
|
|
||||||
|
this.renderPieces(gameId, state.pieces);
|
||||||
|
this.updateHeader(gameId, state.currentPlayer, state.moveHistory.length, state.isCheck);
|
||||||
|
this.setResult(gameId, result);
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleMoveUpdate(gameId, moveDto) {
|
||||||
|
if (!this.games.has(gameId)) return;
|
||||||
|
|
||||||
|
await this.renderGame(gameId);
|
||||||
|
this.highlightMove(gameId, moveDto);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderPieces(gameId, pieces) {
|
||||||
|
this.clearBoard(gameId);
|
||||||
|
|
||||||
|
pieces.forEach(piece => {
|
||||||
|
const square = document.getElementById(`sq-${gameId}-${piece.row * 8 + piece.col}`);
|
||||||
|
|
||||||
|
if (!square) return;
|
||||||
|
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = this.pieceImageUrl(piece);
|
||||||
|
img.alt = piece.type;
|
||||||
|
img.className = "chessPiece";
|
||||||
|
img.draggable = false;
|
||||||
|
square.appendChild(img);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearBoard(gameId) {
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
const square = document.getElementById(`sq-${gameId}-${i}`);
|
||||||
|
|
||||||
|
if (!square) continue;
|
||||||
|
|
||||||
|
square.innerHTML = "";
|
||||||
|
square.classList.remove("previous-start", "previous-end");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightMove(gameId, moveDto) {
|
||||||
|
document.getElementById(`sq-${gameId}-${moveDto.sourceRow * 8 + moveDto.sourceCol}`)?.classList.add("previous-start");
|
||||||
|
document.getElementById(`sq-${gameId}-${moveDto.targetRow * 8 + moveDto.targetCol}`)?.classList.add("previous-end");
|
||||||
|
},
|
||||||
|
|
||||||
|
updateHeader(gameId, currentPlayer, moveCount, isCheck) {
|
||||||
|
const stored = this.games.get(gameId);
|
||||||
|
const header = document.getElementById(`header-${gameId}`);
|
||||||
|
|
||||||
|
if (stored && header)
|
||||||
|
header.textContent = this.headerText(stored, currentPlayer, moveCount, isCheck);
|
||||||
|
},
|
||||||
|
|
||||||
|
gameLabel(stored) {
|
||||||
|
if (stored.isComputerVsComputer) return "CPU vs CPU";
|
||||||
|
if (stored.isVsComputer) return "Vs CPU";
|
||||||
|
return "Player vs Player";
|
||||||
|
},
|
||||||
|
|
||||||
|
headerText(stored, currentPlayer, moveCount, isCheck) {
|
||||||
|
if (stored.result)
|
||||||
|
return `${this.gameLabel(stored)} · move ${moveCount} · final`;
|
||||||
|
|
||||||
|
const check = isCheck ? " • check" : "";
|
||||||
|
return `${this.gameLabel(stored)} · move ${moveCount} · ${currentPlayer} to move${check}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
resultTextFromState(state) {
|
||||||
|
if (state.isCheckmate)
|
||||||
|
return `${state.currentPlayer === "White" ? "Black" : "White"} wins by checkmate`;
|
||||||
|
if (state.isStalemate)
|
||||||
|
return "Draw — stalemate";
|
||||||
|
if (state.isThreefoldRepetition)
|
||||||
|
return "Draw — threefold repetition";
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
setResult(gameId, text) {
|
||||||
|
const card = document.getElementById(`card-${gameId}`);
|
||||||
|
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
let banner = card.querySelector(".gameResult");
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
banner?.remove();
|
||||||
|
card.querySelector(".copyPgnBtn")?.remove();
|
||||||
|
card.classList.remove("over");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!banner) {
|
||||||
|
banner = document.createElement("div");
|
||||||
|
banner.className = "gameResult";
|
||||||
|
card.appendChild(banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
banner.textContent = text;
|
||||||
|
card.classList.add("over");
|
||||||
|
this.addCopyPgn(gameId, card);
|
||||||
|
},
|
||||||
|
|
||||||
|
addCopyPgn(gameId, card) {
|
||||||
|
if (card.querySelector(".copyPgnBtn")) return;
|
||||||
|
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "copyPgnBtn";
|
||||||
|
btn.textContent = "Copy PGN";
|
||||||
|
btn.onclick = (event) => { event.stopPropagation(); this.copyPgn(gameId); };
|
||||||
|
card.appendChild(btn);
|
||||||
|
|
||||||
|
// Prefetch now (while the game is still in memory) so copy works during the
|
||||||
|
// brief window before the finished game is cleaned up.
|
||||||
|
fetch(`/api/chess/${gameId}/pgn`)
|
||||||
|
.then(r => r.ok ? r.text() : null)
|
||||||
|
.then(t => { if (t) this.pgns.set(gameId, t); })
|
||||||
|
.catch(() => { });
|
||||||
|
},
|
||||||
|
|
||||||
|
async copyPgn(gameId) {
|
||||||
|
let pgn = this.pgns.get(gameId);
|
||||||
|
|
||||||
|
if (!pgn) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/chess/${gameId}/pgn`);
|
||||||
|
if (r.ok) pgn = await r.text();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pgn) {
|
||||||
|
alert("PGN is no longer available for this game.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(pgn);
|
||||||
|
alert("📋 PGN copied to clipboard!");
|
||||||
|
} catch {
|
||||||
|
alert("Couldn't access the clipboard. Here's the PGN:\n\n" + pgn);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
enterFullscreen(gameId) {
|
||||||
|
document.getElementById(`card-${gameId}`)?.classList.add("fullscreen");
|
||||||
|
document.body.classList.add("fullscreen-open");
|
||||||
|
},
|
||||||
|
|
||||||
|
exitFullscreen(gameId, event) {
|
||||||
|
event?.stopPropagation();
|
||||||
|
document.getElementById(`card-${gameId}`)?.classList.remove("fullscreen");
|
||||||
|
document.body.classList.remove("fullscreen-open");
|
||||||
|
},
|
||||||
|
|
||||||
|
pieceImageUrl(piece) {
|
||||||
|
const color = piece.color === 0 ? "White" : "Black";
|
||||||
|
return `/images/Chess Images/${color}${this.pieceTypeNames[piece.type]}.svg`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("load", () => Spectate.init());
|
||||||
|
|
||||||
|
console.log("Spectate.js loaded");
|
||||||
@@ -1,5 +1,52 @@
|
|||||||
|
let lastPgn = null;
|
||||||
|
|
||||||
|
async function showCopyPgn(gameId) {
|
||||||
|
try {
|
||||||
|
lastPgn = await ChessAPI.getPgn(gameId);
|
||||||
|
const btn = document.getElementById("copyPgnBtn");
|
||||||
|
if (btn) btn.style.display = "";
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Could not load PGN.", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPgn() {
|
||||||
|
if (!lastPgn) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(lastPgn);
|
||||||
|
alert("📋 PGN copied to clipboard!");
|
||||||
|
} catch {
|
||||||
|
alert("Couldn't access the clipboard. Here's the PGN:\n\n" + lastPgn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCopyPgn() {
|
||||||
|
lastPgn = null;
|
||||||
|
const btn = document.getElementById("copyPgnBtn");
|
||||||
|
if (btn) btn.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
window.copyPgn = copyPgn;
|
||||||
|
window.showCopyPgn = showCopyPgn;
|
||||||
|
|
||||||
|
async function forfeitCurrentGame() {
|
||||||
|
const gameId = ChessUtils.getCookie("chessGameId");
|
||||||
|
const playerId = ChessUtils.getCookie("chessPlayerId");
|
||||||
|
|
||||||
|
if (!gameId || !playerId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ChessAPI.forfeit(gameId, playerId);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Could not forfeit previous game.", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function startNewGame() {
|
async function startNewGame() {
|
||||||
await ChessSignalR.stopConnection();
|
await ChessSignalR.stopConnection();
|
||||||
|
await forfeitCurrentGame();
|
||||||
|
resetCopyPgn();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const gameData = await ChessAPI.joinGame();
|
const gameData = await ChessAPI.joinGame();
|
||||||
@@ -25,6 +72,8 @@ async function startNewGame() {
|
|||||||
|
|
||||||
async function startCPUGame() {
|
async function startCPUGame() {
|
||||||
await ChessSignalR.stopConnection();
|
await ChessSignalR.stopConnection();
|
||||||
|
await forfeitCurrentGame();
|
||||||
|
resetCopyPgn();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const difficulty = await ChessModals.promptDifficulty();
|
const difficulty = await ChessModals.promptDifficulty();
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine,
|
|||||||
/* Compute the best move for the given position.
|
/* Compute the best move for the given position.
|
||||||
* engine : handle from engine_create.
|
* engine : handle from engine_create.
|
||||||
* fen : null-terminated UTF-8 FEN of the position to move from.
|
* fen : null-terminated UTF-8 FEN of the position to move from.
|
||||||
|
* history : optional null-terminated UTF-8 list of the prior positions since the
|
||||||
|
* last irreversible move (capture/pawn move), one FEN per line, oldest
|
||||||
|
* first, NOT including `fen`. Lets the engine detect threefold/50-move
|
||||||
|
* draws that the FEN alone can't carry. May be NULL or empty.
|
||||||
* out_buf : host-owned buffer the engine writes the UCI move into,
|
* out_buf : host-owned buffer the engine writes the UCI move into,
|
||||||
* as a null-terminated ASCII string (e.g. "e2e4\0").
|
* as a null-terminated ASCII string (e.g. "e2e4\0").
|
||||||
* out_len : capacity of out_buf in bytes (host passes >= 8).
|
* out_len : capacity of out_buf in bytes (host passes >= 8).
|
||||||
@@ -64,6 +68,7 @@ CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine,
|
|||||||
* MUST NOT write more than out_len bytes including the NUL terminator. */
|
* MUST NOT write more than out_len bytes including the NUL terminator. */
|
||||||
CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
|
CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
|
||||||
const char* fen,
|
const char* fen,
|
||||||
|
const char* history,
|
||||||
char* out_buf,
|
char* out_buf,
|
||||||
int out_len);
|
int out_len);
|
||||||
|
|
||||||
|
|||||||
@@ -18,15 +18,66 @@
|
|||||||
#include "movegen.h"
|
#include "movegen.h"
|
||||||
#include "uci.h"
|
#include "uci.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <limits>
|
#include <mutex>
|
||||||
#include <new>
|
#include <new>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
/* Internal engine state. Put your search tables, transposition table, etc. here. */
|
/* Search score constants. Scores are side-to-move-relative (negamax): positive is
|
||||||
|
* good for whoever is to move. MATE_BOUND is the threshold above which a score is a
|
||||||
|
* "mate in N" rather than a positional eval; INF is the window sentinel (kept above
|
||||||
|
* MATE so negating it can never hit signed-overflow UB the way INT_MIN would). */
|
||||||
|
static constexpr int MATE = 200000;
|
||||||
|
static constexpr int MATE_BOUND = MATE - 1000;
|
||||||
|
static constexpr int INF = 1000000;
|
||||||
|
|
||||||
|
/* Bound kind stored in a TT entry. LOWER = a fail-high (true score >= stored),
|
||||||
|
* UPPER = a fail-low (true score <= stored), EXACT = fully resolved. */
|
||||||
|
enum class Bound : uint8_t { NONE, EXACT, LOWER, UPPER };
|
||||||
|
|
||||||
|
/* One shared, process-wide transposition table backs every game (every engine
|
||||||
|
* handle), so analysis persists and is reused across games. It is lock-free: each
|
||||||
|
* slot is two 64-bit words — `data` (the packed payload) and `xorKey` (the Zobrist
|
||||||
|
* key XOR-ed with `data`). A reader recovers the key as `xorKey ^ data`; if two
|
||||||
|
* concurrent searches tore the pair, the recovered key won't match and the read is
|
||||||
|
* treated as a miss — never a wrong-but-trusted entry (Hyatt's lockless hashing). */
|
||||||
|
struct TTEntry {
|
||||||
|
std::atomic<uint64_t> xorKey{0};
|
||||||
|
std::atomic<uint64_t> data{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct TranspositionTable {
|
||||||
|
std::unique_ptr<TTEntry[]> entries;
|
||||||
|
size_t mask = 0; /* count - 1; count is a power of two */
|
||||||
|
};
|
||||||
|
|
||||||
|
static TranspositionTable g_tt;
|
||||||
|
static constexpr size_t TT_MEGABYTES = 256;
|
||||||
|
|
||||||
|
/* Pack/unpack the 64-bit payload: score(32) | move(16) | depth(8) | bound(8). A stored
|
||||||
|
* entry always has depth >= 1 and a non-NONE bound, so a real entry never packs to 0 —
|
||||||
|
* letting data == 0 mean "empty slot". */
|
||||||
|
static uint64_t tt_pack(int score, chess::Move move, int depth, Bound bound) {
|
||||||
|
return static_cast<uint64_t>(static_cast<uint32_t>(score))
|
||||||
|
| (static_cast<uint64_t>(move.data) << 32)
|
||||||
|
| (static_cast<uint64_t>(static_cast<uint8_t>(depth)) << 48)
|
||||||
|
| (static_cast<uint64_t>(static_cast<uint8_t>(bound)) << 56);
|
||||||
|
}
|
||||||
|
static int tt_score(uint64_t d) { return static_cast<int32_t>(static_cast<uint32_t>(d & 0xFFFFFFFFu)); }
|
||||||
|
static chess::Move tt_move (uint64_t d) { return chess::Move(static_cast<uint16_t>(d >> 32)); }
|
||||||
|
static int tt_depth(uint64_t d) { return static_cast<int>(static_cast<uint8_t>(d >> 48)); }
|
||||||
|
static Bound tt_bound(uint64_t d) { return static_cast<Bound>(static_cast<uint8_t>(d >> 56)); }
|
||||||
|
|
||||||
|
/* Internal engine state. One ChessEngine = one game. The table is NOT here: it is the
|
||||||
|
* shared g_tt above. */
|
||||||
struct ChessEngine {
|
struct ChessEngine {
|
||||||
int skill = 20; /* 1..20 from the UI; controls search depth */
|
int skill = 20; /* 1..20 from the UI; controls search depth */
|
||||||
};
|
};
|
||||||
@@ -57,10 +108,29 @@ static int parse_skill(const char* options, int fallback) {
|
|||||||
return v < 1 ? 1 : v > 20 ? 20 : v;
|
return v < 1 ? 1 : v > 20 ? 20 : v;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no move
|
/* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no
|
||||||
* ordering or quiescence yet, so deep fixed-depth runs get expensive quickly. */
|
* quiescence yet, so deep fixed-depth runs get expensive quickly. */
|
||||||
static int depth_for_skill(int skill) {
|
static int depth_for_skill(int skill) {
|
||||||
return skill; /* skill 1 -> 2 plies ... skill 20 -> 7 plies */
|
return skill; /* skill N -> N plies */
|
||||||
|
}
|
||||||
|
|
||||||
|
static size_t floor_pow2(size_t n) {
|
||||||
|
size_t p = 1;
|
||||||
|
while ((p << 1) != 0 && (p << 1) <= n) p <<= 1;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allocate the shared table exactly once, to the largest power-of-two entry count that
|
||||||
|
* fits in TT_MEGABYTES. Power-of-two count lets indexing use `key & mask`. Thread-safe:
|
||||||
|
* call_once guards the first concurrent engine_create. Entries start zeroed (empty). */
|
||||||
|
static void ensure_tt() {
|
||||||
|
static std::once_flag once;
|
||||||
|
std::call_once(once, [] {
|
||||||
|
size_t count = floor_pow2((TT_MEGABYTES << 20) / sizeof(TTEntry));
|
||||||
|
if (count < 1) count = 1;
|
||||||
|
g_tt.entries = std::make_unique<TTEntry[]>(count);
|
||||||
|
g_tt.mask = count - 1;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Positional multiplier in [0.5, 2.0] based on a square's distance from the four
|
/* Positional multiplier in [0.5, 2.0] based on a square's distance from the four
|
||||||
@@ -178,10 +248,75 @@ static int evaluate(const chess::Position& pos) {
|
|||||||
return score;
|
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);
|
||||||
|
return whiteToMove ? s : -s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mate scores are "mate in N from THIS node", so they must be re-anchored to the
|
||||||
|
* probing node's ply when crossing the TT (store adds ply, retrieve subtracts it).
|
||||||
|
* Non-mate scores pass through untouched. */
|
||||||
|
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. The TT's best move (if any) goes first, then checks, then captures
|
||||||
|
* by MVV-LVA (grab the most valuable victim with the least valuable attacker).
|
||||||
|
* `scoreChecks` gates the expensive gives_check term to near-leaf nodes. */
|
||||||
|
static int order_score(chess::Position& pos, chess::Move m, chess::Move ttMove, bool scoreChecks) {
|
||||||
|
if (m == ttMove)
|
||||||
|
return 2000000; /* dwarfs any capture/check score below */
|
||||||
|
|
||||||
|
int score = 0;
|
||||||
|
|
||||||
|
if (scoreChecks && pos.gives_check(m))
|
||||||
|
score += 1000;
|
||||||
|
|
||||||
|
chess::Piece victim = pos.piece_on(m.to());
|
||||||
|
if (victim != chess::NO_PIECE)
|
||||||
|
score += 100 + 10 * piece_value(chess::type_of(victim))
|
||||||
|
- piece_value(chess::type_of(pos.piece_on(m.from())));
|
||||||
|
else if (m.type() == chess::EN_PASSANT)
|
||||||
|
score += 100 + 10 * piece_value(chess::PAWN);
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sort the move list in place, best-scoring first. Scores are computed once up
|
||||||
|
* front so gives_check isn't re-evaluated on every comparison. ttMove may be
|
||||||
|
* MOVE_NONE, in which case no move matches it and ordering falls back to captures. */
|
||||||
|
static void order_moves(chess::Position& pos, chess::MoveList& moves, chess::Move ttMove, bool scoreChecks) {
|
||||||
|
struct ScoredMove { int score; chess::Move move; };
|
||||||
|
ScoredMove scored[256];
|
||||||
|
|
||||||
|
for (int i = 0; i < moves.size(); i++)
|
||||||
|
scored[i] = { order_score(pos, moves.moves[i], ttMove, scoreChecks), moves.moves[i] };
|
||||||
|
|
||||||
|
std::sort(scored, scored + moves.size(),
|
||||||
|
[](const ScoredMove& a, const ScoredMove& b) { return a.score > b.score; });
|
||||||
|
|
||||||
|
for (int i = 0; i < moves.size(); i++)
|
||||||
|
moves.moves[i] = scored[i].move;
|
||||||
|
}
|
||||||
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
|
|
||||||
CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) {
|
CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) {
|
||||||
ensure_initialized();
|
ensure_initialized();
|
||||||
|
ensure_tt();
|
||||||
auto* e = new (std::nothrow) ChessEngine();
|
auto* e = new (std::nothrow) ChessEngine();
|
||||||
if (!e) return nullptr;
|
if (!e) return nullptr;
|
||||||
e->skill = parse_skill(options, e->skill);
|
e->skill = parse_skill(options, e->skill);
|
||||||
@@ -195,45 +330,93 @@ CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine,
|
|||||||
return CHESS_OK; /* TODO: store options */
|
return CHESS_OK; /* TODO: store options */
|
||||||
}
|
}
|
||||||
|
|
||||||
static int alpha_beta(chess::Position& pos, int depth, int maxDepth, int bestForWhite, int bestForBlack, bool whiteToMove) {
|
/* Negamax alpha-beta over the shared transposition table. `maxDepth` is the searching
|
||||||
if (depth == maxDepth)
|
* bot's difficulty (its root depth); `depth` is remaining depth (draft); `ply` is
|
||||||
return evaluate(pos);
|
* distance from the root (mate scoring only). Scores are side-to-move-relative.
|
||||||
|
* Fail-soft: returns the true best found even outside [alpha, beta]. */
|
||||||
|
static int negamax(chess::Position& pos, int maxDepth, int depth, int ply,
|
||||||
|
int alpha, int beta, bool whiteToMove, uint64_t& nodes) {
|
||||||
|
nodes++;
|
||||||
|
|
||||||
chess::MoveList moves;
|
/* A draw is 0 even at the search horizon, and the TT key doesn't encode repetition
|
||||||
|
* history, so this must come before both the leaf eval and any TT probe. */
|
||||||
|
if (ply > 0 && pos.is_draw())
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
if (depth <= 0)
|
||||||
|
return evaluate_stm(pos, whiteToMove);
|
||||||
|
|
||||||
|
const uint64_t key = pos.key();
|
||||||
|
TTEntry& slot = g_tt.entries[key & g_tt.mask];
|
||||||
|
const uint64_t data = slot.data.load(std::memory_order_relaxed);
|
||||||
|
const uint64_t xkey = slot.xorKey.load(std::memory_order_relaxed);
|
||||||
|
|
||||||
|
chess::Move ttMove = chess::MOVE_NONE;
|
||||||
|
|
||||||
|
if (data != 0 && (xkey ^ data) == key) { /* lockless: XOR check rejects torn reads */
|
||||||
|
ttMove = tt_move(data); /* always reusable for ordering */
|
||||||
|
int edepth = tt_depth(data);
|
||||||
|
Bound b = tt_bound(data);
|
||||||
|
|
||||||
|
/* Trust the score only if it was searched deep enough for this node AND no deeper
|
||||||
|
* than this bot's own strength — so a weak bot can't borrow a stronger game's
|
||||||
|
* deeper analysis (it still gets the move for ordering, which can't leak strength). */
|
||||||
|
if (edepth >= depth && edepth <= maxDepth) {
|
||||||
|
int s = score_from_tt(tt_score(data), ply);
|
||||||
|
if (b == Bound::EXACT) return s;
|
||||||
|
if (b == Bound::LOWER && s >= beta) return s;
|
||||||
|
if (b == Bound::UPPER && s <= alpha) return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chess::MoveList moves;
|
||||||
pos.generate_legal(moves);
|
pos.generate_legal(moves);
|
||||||
|
|
||||||
if (moves.size() == 0)
|
if (moves.size() == 0)
|
||||||
return pos.is_draw() ? 0 : whiteToMove ? -200000 + depth : 200000 - depth;
|
return pos.is_draw() ? 0 : -MATE + ply; /* checkmate against side to move */
|
||||||
|
|
||||||
|
order_moves(pos, moves, ttMove, depth <= 2);
|
||||||
|
|
||||||
|
const int alphaOrig = alpha;
|
||||||
|
int best = -INF;
|
||||||
|
chess::Move bestMove = chess::MOVE_NONE;
|
||||||
|
|
||||||
for (int i = 0; i < moves.size(); i++) {
|
for (int i = 0; i < moves.size(); i++) {
|
||||||
chess::Move move = moves.moves[i];
|
chess::Move move = moves.moves[i];
|
||||||
pos.do_move(move);
|
pos.do_move(move);
|
||||||
int moveScore = alpha_beta(pos, depth + 1, maxDepth, bestForWhite, bestForBlack, !whiteToMove);
|
int score = -negamax(pos, maxDepth, depth - 1, ply + 1, -beta, -alpha, !whiteToMove, nodes);
|
||||||
if (whiteToMove) {
|
|
||||||
if (moveScore >= bestForBlack) {
|
|
||||||
pos.undo_move(move);
|
|
||||||
return bestForBlack;
|
|
||||||
}
|
|
||||||
if (moveScore > bestForWhite)
|
|
||||||
bestForWhite = moveScore;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if (moveScore <= bestForWhite) {
|
|
||||||
pos.undo_move(move);
|
|
||||||
return bestForWhite;
|
|
||||||
}
|
|
||||||
if (moveScore < bestForBlack)
|
|
||||||
bestForBlack = moveScore;
|
|
||||||
}
|
|
||||||
|
|
||||||
pos.undo_move(move);
|
pos.undo_move(move);
|
||||||
|
|
||||||
|
if (score > best) {
|
||||||
|
best = score;
|
||||||
|
bestMove = move;
|
||||||
|
}
|
||||||
|
if (best > alpha)
|
||||||
|
alpha = best;
|
||||||
|
if (best >= beta)
|
||||||
|
break; /* fail-high cutoff */
|
||||||
}
|
}
|
||||||
|
|
||||||
return whiteToMove ? bestForWhite : bestForBlack;
|
Bound flag = best <= alphaOrig ? Bound::UPPER
|
||||||
|
: best >= beta ? Bound::LOWER
|
||||||
|
: Bound::EXACT;
|
||||||
|
|
||||||
|
/* Depth-preferred replacement: keep the deepest analysis of each slot. The stored
|
||||||
|
* payload is written before the xorKey so any concurrent reader that catches a
|
||||||
|
* half-update fails the XOR check and treats it as a miss. */
|
||||||
|
int storedDepth = (data == 0) ? -1 : tt_depth(data);
|
||||||
|
if (depth >= storedDepth) {
|
||||||
|
uint64_t packed = tt_pack(score_to_tt(best, ply), bestMove, depth, flag);
|
||||||
|
slot.data.store(packed, std::memory_order_relaxed);
|
||||||
|
slot.xorKey.store(key ^ packed, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
|
CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
|
||||||
const char* fen,
|
const char* fen,
|
||||||
|
const char* history,
|
||||||
char* out_buf,
|
char* out_buf,
|
||||||
int out_len) {
|
int out_len) {
|
||||||
if (!engine) return CHESS_ERR_NULL_HANDLE;
|
if (!engine) return CHESS_ERR_NULL_HANDLE;
|
||||||
@@ -242,31 +425,62 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine,
|
|||||||
auto held = std::make_unique<chess::Position>(chess::Position::from_fen(fen));
|
auto held = std::make_unique<chess::Position>(chess::Position::from_fen(fen));
|
||||||
chess::Position& pos = *held;
|
chess::Position& pos = *held;
|
||||||
bool whiteToMove = pos.side_to_move() == chess::WHITE;
|
bool whiteToMove = pos.side_to_move() == chess::WHITE;
|
||||||
|
|
||||||
|
/* Seed the prior positions (one FEN per line) so is_draw() sees repetitions and
|
||||||
|
* the 50-move count that the current FEN alone can't express. */
|
||||||
|
if (history && *history) {
|
||||||
|
std::vector<uint64_t> priorKeys;
|
||||||
|
const char* p = history;
|
||||||
|
while (*p) {
|
||||||
|
const char* nl = std::strchr(p, '\n');
|
||||||
|
size_t len = nl ? static_cast<size_t>(nl - p) : std::strlen(p);
|
||||||
|
if (len > 0)
|
||||||
|
priorKeys.push_back(chess::Position::from_fen(std::string(p, len)).key());
|
||||||
|
if (!nl) break;
|
||||||
|
p = nl + 1;
|
||||||
|
}
|
||||||
|
if (!priorKeys.empty())
|
||||||
|
pos.seed_history(priorKeys.data(), static_cast<int>(priorKeys.size()));
|
||||||
|
}
|
||||||
|
|
||||||
chess::MoveList moves;
|
chess::MoveList moves;
|
||||||
pos.generate_legal(moves);
|
pos.generate_legal(moves);
|
||||||
if (moves.size() == 0)
|
if (moves.size() == 0)
|
||||||
return CHESS_ERR_NO_MOVE;
|
return CHESS_ERR_NO_MOVE;
|
||||||
|
|
||||||
|
uint64_t nodes = 0;
|
||||||
int maxDepth = depth_for_skill(engine->skill);
|
int maxDepth = depth_for_skill(engine->skill);
|
||||||
|
chess::Move bestMove = moves.moves[0]; /* guaranteed-legal fallback */
|
||||||
|
|
||||||
int bestForWhite = std::numeric_limits<int>::min();
|
/* Iterative deepening: each depth seeds the next depth's move ordering (via the
|
||||||
int bestForBlack = std::numeric_limits<int>::max();
|
* previous best move and the TT it filled), which makes the deeper search prune
|
||||||
chess::Move bestMove = moves.moves[0];
|
* far harder than searching to maxDepth cold. */
|
||||||
|
for (int d = 1; d <= maxDepth; d++) {
|
||||||
|
int alpha = -INF, beta = INF;
|
||||||
|
chess::Move iterBest = bestMove;
|
||||||
|
int iterScore = -INF;
|
||||||
|
|
||||||
for (int i = 0; i < moves.size(); i++) {
|
order_moves(pos, moves, iterBest, true);
|
||||||
chess::Move move = moves.moves[i];
|
|
||||||
pos.do_move(move);
|
|
||||||
int score = alpha_beta(pos, 1, maxDepth, bestForWhite, bestForBlack, !whiteToMove);
|
|
||||||
pos.undo_move(move);
|
|
||||||
|
|
||||||
if (whiteToMove && score > bestForWhite) {
|
for (int i = 0; i < moves.size(); i++) {
|
||||||
bestForWhite = score;
|
chess::Move move = moves.moves[i];
|
||||||
bestMove = move;
|
pos.do_move(move);
|
||||||
}
|
int score = -negamax(pos, maxDepth, d - 1, 1, -beta, -alpha, !whiteToMove, nodes);
|
||||||
else if (!whiteToMove && score < bestForBlack) {
|
pos.undo_move(move);
|
||||||
bestForBlack = score;
|
|
||||||
bestMove = move;
|
if (score > iterScore) {
|
||||||
|
iterScore = score;
|
||||||
|
iterBest = move;
|
||||||
|
}
|
||||||
|
if (score > alpha)
|
||||||
|
alpha = score;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bestMove = iterBest; /* commit only a fully completed iteration */
|
||||||
|
|
||||||
|
std::fprintf(stderr, "depth %d nodes %llu best %s score %d\n",
|
||||||
|
d, static_cast<unsigned long long>(nodes),
|
||||||
|
chess::move_to_uci(iterBest).c_str(), iterScore);
|
||||||
}
|
}
|
||||||
|
|
||||||
return copy_out(chess::move_to_uci(bestMove).c_str(), out_buf, out_len);
|
return copy_out(chess::move_to_uci(bestMove).c_str(), out_buf, out_len);
|
||||||
|
|||||||
@@ -316,6 +316,18 @@ bool Position::insufficient_material() const {
|
|||||||
return minors <= 1; // KvK, KvKN, KvKB
|
return minors <= 1; // KvK, KvKN, KvKB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Position::seed_history(const uint64_t* priorKeys, int count) {
|
||||||
|
if (count <= 0) return;
|
||||||
|
if (count > 1000) count = 1000; // leave headroom in repKeys for search plies
|
||||||
|
|
||||||
|
uint64_t current = zkey; // from_fen placed this at repKeys[0]
|
||||||
|
for (int i = 0; i < count; ++i)
|
||||||
|
repKeys[i] = priorKeys[i];
|
||||||
|
repKeys[count] = current;
|
||||||
|
repCount = count + 1;
|
||||||
|
rule50 = count; // == half-moves since the last irreversible move
|
||||||
|
}
|
||||||
|
|
||||||
bool Position::is_draw() const {
|
bool Position::is_draw() const {
|
||||||
if (rule50 >= 100) return true;
|
if (rule50 >= 100) return true;
|
||||||
if (insufficient_material()) return true;
|
if (insufficient_material()) return true;
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ public:
|
|||||||
void do_move(Move m);
|
void do_move(Move m);
|
||||||
void undo_move(Move m);
|
void undo_move(Move m);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
void seed_history(const uint64_t* priorKeys, int count);
|
||||||
|
|
||||||
// --- freebies ---
|
// --- freebies ---
|
||||||
uint64_t key() const { return zkey; }
|
uint64_t key() const { return zkey; }
|
||||||
bool is_draw() const; // 50-move + threefold + insufficient material
|
bool is_draw() const; // 50-move + threefold + insufficient material
|
||||||
|
|||||||
Reference in New Issue
Block a user