From 2db95cec3408ef07993dda4736536dde2b703aca Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Mon, 8 Jun 2026 16:21:29 -0600 Subject: [PATCH 1/5] Add spectator watch page, forfeit-on-new-game, CPU self-play, and threefold repetition - /watch live feed of active games with click-to-fullscreen and per-game back button - Starting a new game forfeits the in-progress game; opponent is flagged the winner - Watch CPU vs CPU self-play games; results shown for ~30s before cleanup - Detect threefold repetition in the C# rules layer and end the game as a draw Co-Authored-By: Claude Opus 4.8 (1M context) --- JoshHeaps.Net/Controllers/ChessController.cs | 131 +++++++++- JoshHeaps.Net/Models/ForfeitDto.cs | 7 + JoshHeaps.Net/Models/GameState.cs | 10 + JoshHeaps.Net/Models/MoveResultDto.cs | 1 + JoshHeaps.Net/Pages/Chess.cshtml | 1 + JoshHeaps.Net/Pages/Index.cshtml | 1 + JoshHeaps.Net/Pages/Watch.cshtml | 34 +++ JoshHeaps.Net/Pages/Watch.cshtml.cs | 11 + .../Services/Implementations/ChessService.cs | 22 +- JoshHeaps.Net/wwwroot/css/chess/site.css | 11 + JoshHeaps.Net/wwwroot/css/chess/spectate.css | 134 ++++++++++ .../wwwroot/js/ChessScripts/ChessAPI.js | 11 + .../wwwroot/js/ChessScripts/ChessSignalR.js | 15 ++ .../wwwroot/js/ChessScripts/Spectate.js | 243 ++++++++++++++++++ .../wwwroot/js/ChessScripts/chessMain.js | 15 ++ 15 files changed, 642 insertions(+), 5 deletions(-) create mode 100644 JoshHeaps.Net/Models/ForfeitDto.cs create mode 100644 JoshHeaps.Net/Pages/Watch.cshtml create mode 100644 JoshHeaps.Net/Pages/Watch.cshtml.cs create mode 100644 JoshHeaps.Net/wwwroot/css/chess/spectate.css create mode 100644 JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index 29bf494..54e9f2e 100644 --- a/JoshHeaps.Net/Controllers/ChessController.cs +++ b/JoshHeaps.Net/Controllers/ChessController.cs @@ -1,6 +1,8 @@ -using JoshHeaps.Net.Models; +using JoshHeaps.Net.Hubs; +using JoshHeaps.Net.Models; using JoshHeaps.Net.Services.Interfaces; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; using System.Collections.Concurrent; namespace JoshHeaps.Net.Controllers; @@ -11,7 +13,8 @@ public class ChessController( IChessService chessService, IBackgroundTaskQueue queue, IChessEngineFactory engineFactory, - IComputerMoveOrchestrator orchestrator) : ControllerBase + IComputerMoveOrchestrator orchestrator, + IHubContext chessHub) : ControllerBase { /// /// Store of ongoing games. @@ -23,6 +26,8 @@ public class ChessController( private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1); private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1); private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1); + private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30); /// /// Create a new chess game and store it in-memory. @@ -70,6 +75,31 @@ 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. + /// + [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 }); + } + /// /// Joins the "pool" of chess players. /// Test code expects to receive a GUID for the player @@ -112,6 +142,30 @@ public class ChessController( }); } + /// + /// List in-progress games for spectators: both sides present and the game not yet decided. + /// + [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); + } + /// /// Get the state of an existing game by ID. /// @@ -128,6 +182,7 @@ public class ChessController( gameState.IsCheck, gameState.IsCheckmate, gameState.IsStalemate, + gameState.IsThreefoldRepetition, EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null, gameState.WhiteCanCastleKingside, gameState.WhiteCanCastleQueenside, @@ -180,19 +235,51 @@ public class ChessController( if (!result.Success) return BadRequest(result); - if (result.IsCheckmate || result.IsStalemate) + var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition; + + if (isGameOver) ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout); else if (gameState.IsVsComputer) ScheduleRemoveGame(gameState.GameId, _computerGameTimeout); else 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!)); return Ok(result); } + /// + /// 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). + /// + [HttpPost("forfeit")] + public async Task 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(); + } + /// /// Get the legal moves for a specific piece in a specific game. /// @@ -226,6 +313,42 @@ public class ChessController( return Ok(allMoves); } + /// + /// 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. + /// + 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) { if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts)) diff --git a/JoshHeaps.Net/Models/ForfeitDto.cs b/JoshHeaps.Net/Models/ForfeitDto.cs new file mode 100644 index 0000000..6fde214 --- /dev/null +++ b/JoshHeaps.Net/Models/ForfeitDto.cs @@ -0,0 +1,7 @@ +namespace JoshHeaps.Net.Models; + +public class ForfeitDto +{ + public Guid GameId { get; set; } + public Guid PlayerId { get; set; } +} diff --git a/JoshHeaps.Net/Models/GameState.cs b/JoshHeaps.Net/Models/GameState.cs index 63579f9..5781639 100644 --- a/JoshHeaps.Net/Models/GameState.cs +++ b/JoshHeaps.Net/Models/GameState.cs @@ -28,10 +28,18 @@ public class GameState public bool IsCheck { get; set; } public bool IsCheckmate { 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 public List MoveHistory { get; set; } + // Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection. + public List PositionHistory { get; set; } + // A list of all pieces to quickly reference them (optional but convenient). // Alternatively, you can iterate the Board array. public List Pieces { get; set; } @@ -42,6 +50,7 @@ public class GameState public Guid BlackPlayerId { get; set; } public bool IsVsComputer { get; set; } = false; + public bool IsComputerVsComputer { get; set; } = false; public IChessEngine? Computer { get; set; } @@ -54,5 +63,6 @@ public class GameState Board = new ChessPiece[8, 8]; Pieces = []; MoveHistory = []; + PositionHistory = []; } } diff --git a/JoshHeaps.Net/Models/MoveResultDto.cs b/JoshHeaps.Net/Models/MoveResultDto.cs index 81850f2..86d33cb 100644 --- a/JoshHeaps.Net/Models/MoveResultDto.cs +++ b/JoshHeaps.Net/Models/MoveResultDto.cs @@ -7,4 +7,5 @@ public class MoveResultDto public bool IsCheck { get; set; } public bool IsCheckmate { get; set; } public bool IsStalemate { get; set; } + public bool IsThreefoldRepetition { get; set; } } diff --git a/JoshHeaps.Net/Pages/Chess.cshtml b/JoshHeaps.Net/Pages/Chess.cshtml index df65f87..3c7df31 100644 --- a/JoshHeaps.Net/Pages/Chess.cshtml +++ b/JoshHeaps.Net/Pages/Chess.cshtml @@ -24,6 +24,7 @@
+ Watch other games →
diff --git a/JoshHeaps.Net/Pages/Index.cshtml b/JoshHeaps.Net/Pages/Index.cshtml index b71a3b2..be6aa12 100644 --- a/JoshHeaps.Net/Pages/Index.cshtml +++ b/JoshHeaps.Net/Pages/Index.cshtml @@ -61,6 +61,7 @@

Demos

+ diff --git a/JoshHeaps.Net/Pages/Watch.cshtml b/JoshHeaps.Net/Pages/Watch.cshtml new file mode 100644 index 0000000..80635e6 --- /dev/null +++ b/JoshHeaps.Net/Pages/Watch.cshtml @@ -0,0 +1,34 @@ +@page +@model JoshHeaps.Net.Pages.WatchModel +@{ + Layout = "_Layout"; + ViewData["Title"] = "Watch Chess"; +} + +
+

Live Chess

+

Loading games…

+
+ + + +
+ ← Play a game +
+ +
+ +@section Scripts { + + +} + +@section Styles { + + +} diff --git a/JoshHeaps.Net/Pages/Watch.cshtml.cs b/JoshHeaps.Net/Pages/Watch.cshtml.cs new file mode 100644 index 0000000..33484de --- /dev/null +++ b/JoshHeaps.Net/Pages/Watch.cshtml.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace JoshHeaps.Net.Pages +{ + public class WatchModel : PageModel + { + public void OnGet() + { + } + } +} diff --git a/JoshHeaps.Net/Services/Implementations/ChessService.cs b/JoshHeaps.Net/Services/Implementations/ChessService.cs index adcaf82..613eb59 100644 --- a/JoshHeaps.Net/Services/Implementations/ChessService.cs +++ b/JoshHeaps.Net/Services/Implementations/ChessService.cs @@ -35,6 +35,9 @@ public class ChessService : IChessService gameState.CurrentPlayer = PieceColor.White; UpdateCheckStatus(gameState); + + gameState.PositionHistory.Clear(); + gameState.PositionHistory.Add(PositionKey(gameState)); } private static void SetupBlackPieces(GameState gs) @@ -144,16 +147,33 @@ public class ChessService : IChessService var notation = $"{piece.Id}:{piece.Position}->{targetPos}"; gameState.MoveHistory.Add(notation); + var positionKey = PositionKey(gameState); + gameState.PositionHistory.Add(positionKey); + + if (gameState.PositionHistory.Count(k => k == positionKey) >= 3) + gameState.IsThreefoldRepetition = true; + return new MoveResultDto { Success = true, Message = "Move successful.", IsCheck = gameState.IsCheck, IsCheckmate = gameState.IsCheckmate, - IsStalemate = gameState.IsStalemate + IsStalemate = gameState.IsStalemate, + IsThreefoldRepetition = gameState.IsThreefoldRepetition }; } + /// + /// 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. + /// + 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) { var oldPos = piece.Position; diff --git a/JoshHeaps.Net/wwwroot/css/chess/site.css b/JoshHeaps.Net/wwwroot/css/chess/site.css index c0bee9f..310ec56 100644 --- a/JoshHeaps.Net/wwwroot/css/chess/site.css +++ b/JoshHeaps.Net/wwwroot/css/chess/site.css @@ -28,6 +28,17 @@ margin: 2vh; } +#watchLink { + color: #8cd5ed; + text-decoration: none; + margin: 2vh; + order: 2; +} + +#watchLink:hover { + text-decoration: underline; +} + #chessContainer { display: flex; flex-direction: column; diff --git a/JoshHeaps.Net/wwwroot/css/chess/spectate.css b/JoshHeaps.Net/wwwroot/css/chess/spectate.css new file mode 100644 index 0000000..cb29072 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/css/chess/spectate.css @@ -0,0 +1,134 @@ +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; +} + +.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); +} diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js index 19de069..6262164 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js @@ -14,6 +14,14 @@ const ChessAPI = { return await response.json(); }, + 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) { const response = await fetch(`/api/chess/${GameState.currentGameId}/legalMoves/${pieceId}`); if (!response.ok) throw new Error("API failed"); @@ -101,6 +109,9 @@ const ChessAPI = { } else if (moveResult.isStalemate) { alert("🤝 Stalemate!"); gameOver = true; + } else if (moveResult.isThreefoldRepetition) { + alert("🤝 Draw by threefold repetition!"); + gameOver = true; } if (gameOver) { diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js index c12e53a..68f35cc 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js @@ -15,6 +15,10 @@ const ChessSignalR = { await this.handleMoveUpdate(gameId, moveDto, moveResultDto); }); + this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => { + await this.handleGameOver(gameId, winner, reason); + }); + try { await this.connection.start(); console.log("✅ SignalR connected"); @@ -34,6 +38,17 @@ const ChessSignalR = { 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."); + + await this.leaveGame(); + }, + async notifyMoveMade(moveDto, moveResult) { if (this.connection) { await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult); diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js new file mode 100644 index 0000000..c4fea0b --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js @@ -0,0 +1,243 @@ +const Spectate = { + connection: null, + games: new Map(), // gameId -> { isVsComputer, isComputerVsComputer, result } + + 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); + 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.classList.remove("over"); + return; + } + + if (!banner) { + banner = document.createElement("div"); + banner.className = "gameResult"; + card.appendChild(banner); + } + + banner.textContent = text; + card.classList.add("over"); + }, + + 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"); diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js index abae265..9bfda72 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js @@ -1,5 +1,19 @@ +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() { await ChessSignalR.stopConnection(); + await forfeitCurrentGame(); try { const gameData = await ChessAPI.joinGame(); @@ -25,6 +39,7 @@ async function startNewGame() { async function startCPUGame() { await ChessSignalR.stopConnection(); + await forfeitCurrentGame(); try { const difficulty = await ChessModals.promptDifficulty(); From 76b054ee9ddcf0e2ec5b012c6b9f9d6bd8a9004e Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Mon, 8 Jun 2026 17:28:04 -0600 Subject: [PATCH 2/5] More efficient searching --- JoshHeaps.Net/Resources/chess_engine.dll | Bin 146432 -> 157184 bytes native/chess_engine/src/chess_engine.cpp | 55 ++++++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/JoshHeaps.Net/Resources/chess_engine.dll b/JoshHeaps.Net/Resources/chess_engine.dll index 5167dea8ee8b1ccb418b5091b25b70a2916c96a3..f7f7bfdc03c585b3dac8d98228a4d63c8ea2ec43 100644 GIT binary patch delta 39183 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 E12!|8?EnA( delta 32386 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 #include #include #include @@ -57,8 +58,8 @@ static int parse_skill(const char* options, int fallback) { return v < 1 ? 1 : v > 20 ? 20 : v; } -/* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no move - * ordering or quiescence yet, so deep fixed-depth runs get expensive quickly. */ +/* 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) { return skill; /* skill 1 -> 2 plies ... skill 20 -> 7 plies */ } @@ -178,6 +179,52 @@ static int evaluate(const chess::Position& pos) { 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; + } +} + +/* Heuristic for searching the most promising moves first, which makes alpha-beta + * prune far more. Checks rank highest, then captures by MVV-LVA (grab the most + * valuable victim with the least valuable attacker). */ +static int order_score(chess::Position& pos, chess::Move m) { + int score = 0; + + if (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. */ +static void order_moves(chess::Position& pos, chess::MoveList& moves) { + 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]), 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" { CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) { @@ -205,6 +252,8 @@ static int alpha_beta(chess::Position& pos, int depth, int maxDepth, int bestFor if (moves.size() == 0) return pos.is_draw() ? 0 : whiteToMove ? -200000 + depth : 200000 - depth; + order_moves(pos, moves); + for (int i = 0; i < moves.size(); i++) { chess::Move move = moves.moves[i]; pos.do_move(move); @@ -247,6 +296,8 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine, if (moves.size() == 0) return CHESS_ERR_NO_MOVE; + order_moves(pos, moves); + int maxDepth = depth_for_skill(engine->skill); int bestForWhite = std::numeric_limits::min(); From 5345427e0cca30d6b5383fa37075125a2053eb9a Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Mon, 8 Jun 2026 18:58:59 -0600 Subject: [PATCH 3/5] Add transposition table, iterative deepening, and repetition-aware search - Refactor the search to negamax (single side-relative score + alpha/beta window) - Add a shared, process-wide transposition table: lock-free XOR-verified slots, persists across games, with depth-gated and difficulty-capped score reuse so a weak bot can't borrow a stronger game's deeper analysis - Drive the search with iterative deepening, seeding each depth's move ordering from the previous one - Seed prior-position history (Position::seed_history) over a new engine_best_move history parameter so the engine detects threefold/50-move draws the FEN can't carry Co-Authored-By: Claude Opus 4.8 (1M context) --- native/chess_engine/include/chess_engine.h | 5 + native/chess_engine/src/chess_engine.cpp | 271 +++++++++++++++++---- native/chess_engine/src/position.cpp | 12 + native/chess_engine/src/position.h | 5 + 4 files changed, 239 insertions(+), 54 deletions(-) diff --git a/native/chess_engine/include/chess_engine.h b/native/chess_engine/include/chess_engine.h index 0d494c6..9caca72 100644 --- a/native/chess_engine/include/chess_engine.h +++ b/native/chess_engine/include/chess_engine.h @@ -57,6 +57,10 @@ CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine, /* Compute the best move for the given position. * engine : handle from engine_create. * 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, * as a null-terminated ASCII string (e.g. "e2e4\0"). * 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. */ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine, const char* fen, + const char* history, char* out_buf, int out_len); diff --git a/native/chess_engine/src/chess_engine.cpp b/native/chess_engine/src/chess_engine.cpp index 7a567e4..6a113f9 100644 --- a/native/chess_engine/src/chess_engine.cpp +++ b/native/chess_engine/src/chess_engine.cpp @@ -19,15 +19,65 @@ #include "uci.h" #include +#include +#include +#include #include #include -#include +#include #include #include #include +#include -/* 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 xorKey{0}; + std::atomic data{0}; +}; + +struct TranspositionTable { + std::unique_ptr 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(static_cast(score)) + | (static_cast(move.data) << 32) + | (static_cast(static_cast(depth)) << 48) + | (static_cast(static_cast(bound)) << 56); +} +static int tt_score(uint64_t d) { return static_cast(static_cast(d & 0xFFFFFFFFu)); } +static chess::Move tt_move (uint64_t d) { return chess::Move(static_cast(d >> 32)); } +static int tt_depth(uint64_t d) { return 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. */ struct ChessEngine { int skill = 20; /* 1..20 from the UI; controls search depth */ }; @@ -61,7 +111,26 @@ static int parse_skill(const char* options, int fallback) { /* 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) { - 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(count); + g_tt.mask = count - 1; + }); } /* Positional multiplier in [0.5, 2.0] based on a square's distance from the four @@ -179,6 +248,19 @@ static int evaluate(const chess::Position& pos) { 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; @@ -191,12 +273,16 @@ static int piece_value(chess::PieceType pt) { } /* Heuristic for searching the most promising moves first, which makes alpha-beta - * prune far more. Checks rank highest, then captures by MVV-LVA (grab the most - * valuable victim with the least valuable attacker). */ -static int order_score(chess::Position& pos, chess::Move m) { + * 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 (pos.gives_check(m)) + if (scoreChecks && pos.gives_check(m)) score += 1000; chess::Piece victim = pos.piece_on(m.to()); @@ -210,13 +296,14 @@ static int order_score(chess::Position& pos, chess::Move m) { } /* 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. */ -static void order_moves(chess::Position& pos, chess::MoveList& moves) { + * 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]), moves.moves[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; }); @@ -229,6 +316,7 @@ extern "C" { CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) { ensure_initialized(); + ensure_tt(); auto* e = new (std::nothrow) ChessEngine(); if (!e) return nullptr; e->skill = parse_skill(options, e->skill); @@ -242,47 +330,93 @@ CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine, return CHESS_OK; /* TODO: store options */ } -static int alpha_beta(chess::Position& pos, int depth, int maxDepth, int bestForWhite, int bestForBlack, bool whiteToMove) { - if (depth == maxDepth) - return evaluate(pos); +/* Negamax alpha-beta over the shared transposition table. `maxDepth` is the searching + * bot's difficulty (its root depth); `depth` is remaining depth (draft); `ply` is + * 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); 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); + 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++) { - chess::Move move = moves.moves[i]; - pos.do_move(move); - int moveScore = alpha_beta(pos, depth + 1, maxDepth, bestForWhite, bestForBlack, !whiteToMove); - 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; - } - + chess::Move move = moves.moves[i]; + pos.do_move(move); + int score = -negamax(pos, maxDepth, depth - 1, ply + 1, -beta, -alpha, !whiteToMove, nodes); 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, const char* fen, + const char* history, char* out_buf, int out_len) { if (!engine) return CHESS_ERR_NULL_HANDLE; @@ -291,33 +425,62 @@ CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine, auto held = std::make_unique(chess::Position::from_fen(fen)); chess::Position& pos = *held; 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 priorKeys; + const char* p = history; + while (*p) { + const char* nl = std::strchr(p, '\n'); + size_t len = nl ? static_cast(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(priorKeys.size())); + } + chess::MoveList moves; pos.generate_legal(moves); if (moves.size() == 0) return CHESS_ERR_NO_MOVE; - order_moves(pos, moves); - + uint64_t nodes = 0; int maxDepth = depth_for_skill(engine->skill); + chess::Move bestMove = moves.moves[0]; /* guaranteed-legal fallback */ - int bestForWhite = std::numeric_limits::min(); - int bestForBlack = std::numeric_limits::max(); - chess::Move bestMove = moves.moves[0]; + /* Iterative deepening: each depth seeds the next depth's move ordering (via the + * previous best move and the TT it filled), which makes the deeper search prune + * 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++) { - chess::Move move = moves.moves[i]; - pos.do_move(move); - int score = alpha_beta(pos, 1, maxDepth, bestForWhite, bestForBlack, !whiteToMove); - pos.undo_move(move); + order_moves(pos, moves, iterBest, true); - if (whiteToMove && score > bestForWhite) { - bestForWhite = score; - bestMove = move; - } - else if (!whiteToMove && score < bestForBlack) { - bestForBlack = score; - bestMove = move; + for (int i = 0; i < moves.size(); i++) { + chess::Move move = moves.moves[i]; + pos.do_move(move); + int score = -negamax(pos, maxDepth, d - 1, 1, -beta, -alpha, !whiteToMove, nodes); + pos.undo_move(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(nodes), + chess::move_to_uci(iterBest).c_str(), iterScore); } return copy_out(chess::move_to_uci(bestMove).c_str(), out_buf, out_len); diff --git a/native/chess_engine/src/position.cpp b/native/chess_engine/src/position.cpp index 3330b05..065ea19 100644 --- a/native/chess_engine/src/position.cpp +++ b/native/chess_engine/src/position.cpp @@ -316,6 +316,18 @@ bool Position::insufficient_material() const { 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 { if (rule50 >= 100) return true; if (insufficient_material()) return true; diff --git a/native/chess_engine/src/position.h b/native/chess_engine/src/position.h index cc9e99e..8976394 100644 --- a/native/chess_engine/src/position.h +++ b/native/chess_engine/src/position.h @@ -47,6 +47,11 @@ public: void do_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 --- uint64_t key() const { return zkey; } bool is_draw() const; // 50-move + threefold + insufficient material From d87cea572fe1340ebf24f56f739ead42643b2450 Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Mon, 8 Jun 2026 18:59:10 -0600 Subject: [PATCH 4/5] Feed move history and halfmove clock to the engine for repetition detection - Track HalfmoveClock in GameState/ChessService (reset on captures and pawn moves) - Emit the real halfmove clock in ToFen and add GameState.RepetitionHistory() (prior positions since the last irreversible move) in ChessEngineHelpers - Pass that history through IChessEngine.GetBestMoveAsync to the native engine; Stockfish ignores it Co-Authored-By: Claude Opus 4.8 (1M context) --- JoshHeaps.Net/Models/GameState.cs | 4 +++ .../Implementations/ChessEngineHelpers.cs | 25 ++++++++++++++++++- .../Services/Implementations/ChessService.cs | 5 ++++ .../ComputerMoveOrchestrator.cs | 2 +- .../Implementations/CustomChessEngine.cs | 9 ++++--- .../Services/Implementations/Stockfish.cs | 3 ++- .../Services/Interfaces/IChessEngine.cs | 7 +++++- 7 files changed, 47 insertions(+), 8 deletions(-) diff --git a/JoshHeaps.Net/Models/GameState.cs b/JoshHeaps.Net/Models/GameState.cs index 5781639..297dc62 100644 --- a/JoshHeaps.Net/Models/GameState.cs +++ b/JoshHeaps.Net/Models/GameState.cs @@ -40,6 +40,10 @@ public class GameState // Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection. public List PositionHistory { 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). // Alternatively, you can iterate the Board array. public List Pieces { get; set; } diff --git a/JoshHeaps.Net/Services/Implementations/ChessEngineHelpers.cs b/JoshHeaps.Net/Services/Implementations/ChessEngineHelpers.cs index 0845864..ae3f860 100644 --- a/JoshHeaps.Net/Services/Implementations/ChessEngineHelpers.cs +++ b/JoshHeaps.Net/Services/Implementations/ChessEngineHelpers.cs @@ -85,11 +85,34 @@ public static class ChessEngineHelpers /* 5-6) half-move clock + full-move number */ int fullMoves = gs.MoveHistory.Count / 2 + 1; - sb.Append(" 0 ").Append(fullMoves); + sb.Append(' ').Append(gs.HalfmoveClock).Append(' ').Append(fullMoves); return sb.ToString(); } + /// + /// 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. + /// + public static IReadOnlyList 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(); + + var fens = new List(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 ---------- */ private static string GetCastlingFlags(GameState gs) diff --git a/JoshHeaps.Net/Services/Implementations/ChessService.cs b/JoshHeaps.Net/Services/Implementations/ChessService.cs index 613eb59..e99884d 100644 --- a/JoshHeaps.Net/Services/Implementations/ChessService.cs +++ b/JoshHeaps.Net/Services/Implementations/ChessService.cs @@ -28,6 +28,7 @@ public class ChessService : IChessService gameState.BlackCanCastleKingside = true; gameState.BlackCanCastleQueenside = true; gameState.EnPassantTarget = null; + gameState.HalfmoveClock = 0; SetupBlackPieces(gameState); SetupWhitePieces(gameState); @@ -178,6 +179,7 @@ public class ChessService : IChessService { var oldPos = piece.Position; 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); @@ -199,6 +201,9 @@ public class ChessService : IChessService 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 ? PieceColor.Black : PieceColor.White; diff --git a/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs b/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs index 45d24df..21c15b1 100644 --- a/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs +++ b/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs @@ -15,7 +15,7 @@ public sealed class ComputerMoveOrchestrator( { 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( state, diff --git a/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs b/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs index 64e94a7..5675a69 100644 --- a/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs +++ b/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs @@ -28,9 +28,10 @@ public sealed partial class CustomChessEngine : IChessEngine _handle = new EngineSafeHandle(handle); } - public Task GetBestMoveAsync(string fen) => Task.Run(() => GetBestMove(fen)); + public Task GetBestMoveAsync(string fen, IReadOnlyList 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 byte* buffer = stackalloc byte[bufferLength]; @@ -40,7 +41,7 @@ public sealed partial class CustomChessEngine : IChessEngine try { _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) 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)] [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)] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] diff --git a/JoshHeaps.Net/Services/Implementations/Stockfish.cs b/JoshHeaps.Net/Services/Implementations/Stockfish.cs index 82e9b43..bf2d3d9 100644 --- a/JoshHeaps.Net/Services/Implementations/Stockfish.cs +++ b/JoshHeaps.Net/Services/Implementations/Stockfish.cs @@ -74,8 +74,9 @@ public sealed class Stockfish : IChessEngine WaitFor("readyok").GetAwaiter().GetResult(); } - public async Task GetBestMoveAsync(string fen) + public async Task GetBestMoveAsync(string fen, IReadOnlyList historyFens) { + // historyFens is unused: Stockfish tracks repetition from the position it's given. Send($"position fen {fen}"); Send($"go depth {_skill}"); string? best = null; diff --git a/JoshHeaps.Net/Services/Interfaces/IChessEngine.cs b/JoshHeaps.Net/Services/Interfaces/IChessEngine.cs index 4dc5d69..9f7f08e 100644 --- a/JoshHeaps.Net/Services/Interfaces/IChessEngine.cs +++ b/JoshHeaps.Net/Services/Interfaces/IChessEngine.cs @@ -14,6 +14,11 @@ public interface IChessEngine : IAsyncDisposable /// Returns the engine's chosen move in UCI long-algebraic form (e.g. "e2e4", "e7e8q"). ///
/// The current position as a FEN string. + /// + /// 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. + /// /// The selected move as a UCI string. - Task GetBestMoveAsync(string fen); + Task GetBestMoveAsync(string fen, IReadOnlyList historyFens); } From b651683e638c61e93fc549b74295926b7f556d69 Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Mon, 8 Jun 2026 19:14:39 -0600 Subject: [PATCH 5/5] Add PGN export and a Copy PGN button for finished games - Record moves in standard algebraic notation (GameState.SanHistory), built at move time in ChessService (captures, disambiguation, castling, promotion, en passant, and the check/mate suffix) - Add PgnExporter.ToPgn (seven-tag header + movetext + result) and a GET /api/chess/{gameId}/pgn endpoint - Show a Copy PGN button when a game ends on both the play and watch pages, prefetching the PGN so it works during the post-game cleanup window Co-Authored-By: Claude Opus 4.8 (1M context) --- JoshHeaps.Net/Controllers/ChessController.cs | 13 ++++ JoshHeaps.Net/Models/GameState.cs | 4 + JoshHeaps.Net/Pages/Chess.cshtml | 1 + .../Services/Implementations/ChessService.cs | 75 +++++++++++++++++++ .../Services/Implementations/PgnExporter.cs | 50 +++++++++++++ JoshHeaps.Net/wwwroot/css/chess/site.css | 11 +++ JoshHeaps.Net/wwwroot/css/chess/spectate.css | 15 ++++ .../wwwroot/js/ChessScripts/ChessAPI.js | 7 ++ .../wwwroot/js/ChessScripts/ChessSignalR.js | 1 + .../wwwroot/js/ChessScripts/Spectate.js | 44 +++++++++++ .../wwwroot/js/ChessScripts/chessMain.js | 34 +++++++++ 11 files changed, 255 insertions(+) create mode 100644 JoshHeaps.Net/Services/Implementations/PgnExporter.cs diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index 54e9f2e..6e63375 100644 --- a/JoshHeaps.Net/Controllers/ChessController.cs +++ b/JoshHeaps.Net/Controllers/ChessController.cs @@ -1,5 +1,6 @@ using JoshHeaps.Net.Hubs; using JoshHeaps.Net.Models; +using JoshHeaps.Net.Services.Implementations; using JoshHeaps.Net.Services.Interfaces; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; @@ -280,6 +281,18 @@ public class ChessController( return Ok(); } + /// + /// Export a game as PGN (works while the game is still in memory after it ends). + /// + [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"); + } + /// /// Get the legal moves for a specific piece in a specific game. /// diff --git a/JoshHeaps.Net/Models/GameState.cs b/JoshHeaps.Net/Models/GameState.cs index 297dc62..c3c86da 100644 --- a/JoshHeaps.Net/Models/GameState.cs +++ b/JoshHeaps.Net/Models/GameState.cs @@ -40,6 +40,9 @@ public class GameState // Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection. public List PositionHistory { get; set; } + // Moves in standard algebraic notation (SAN), in order, for PGN export. + public List 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; } @@ -68,5 +71,6 @@ public class GameState Pieces = []; MoveHistory = []; PositionHistory = []; + SanHistory = []; } } diff --git a/JoshHeaps.Net/Pages/Chess.cshtml b/JoshHeaps.Net/Pages/Chess.cshtml index 3c7df31..314b112 100644 --- a/JoshHeaps.Net/Pages/Chess.cshtml +++ b/JoshHeaps.Net/Pages/Chess.cshtml @@ -24,6 +24,7 @@
+ Watch other games →
diff --git a/JoshHeaps.Net/Services/Implementations/ChessService.cs b/JoshHeaps.Net/Services/Implementations/ChessService.cs index e99884d..3d89133 100644 --- a/JoshHeaps.Net/Services/Implementations/ChessService.cs +++ b/JoshHeaps.Net/Services/Implementations/ChessService.cs @@ -22,6 +22,7 @@ public class ChessService : IChessService gameState.Pieces.Clear(); gameState.MoveHistory.Clear(); + gameState.SanHistory.Clear(); gameState.WhiteCanCastleKingside = true; gameState.WhiteCanCastleQueenside = true; @@ -141,12 +142,17 @@ public class ChessService : IChessService if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol)) 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); UpdateCheckStatus(gameState); var notation = $"{piece.Id}:{piece.Position}->{targetPos}"; gameState.MoveHistory.Add(notation); + gameState.SanHistory.Add(sanBase + (gameState.IsCheckmate ? "#" : gameState.IsCheck ? "+" : "")); var positionKey = PositionKey(gameState); gameState.PositionHistory.Add(positionKey); @@ -165,6 +171,75 @@ public class ChessService : IChessService }; } + /// + /// 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). + /// + 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}"; + } + + /// + /// 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. + /// + 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", + _ => "" + }; + /// /// 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. diff --git a/JoshHeaps.Net/Services/Implementations/PgnExporter.cs b/JoshHeaps.Net/Services/Implementations/PgnExporter.cs new file mode 100644 index 0000000..1765e23 --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/PgnExporter.cs @@ -0,0 +1,50 @@ +using JoshHeaps.Net.Models; +using System.Text; + +namespace JoshHeaps.Net.Services.Implementations; + +public static class PgnExporter +{ + /// + /// Render a game as PGN: the standard seven-tag header plus the SAN movetext and result. + /// + 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 "*"; + } +} diff --git a/JoshHeaps.Net/wwwroot/css/chess/site.css b/JoshHeaps.Net/wwwroot/css/chess/site.css index 310ec56..bdf63a9 100644 --- a/JoshHeaps.Net/wwwroot/css/chess/site.css +++ b/JoshHeaps.Net/wwwroot/css/chess/site.css @@ -28,6 +28,17 @@ 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; diff --git a/JoshHeaps.Net/wwwroot/css/chess/spectate.css b/JoshHeaps.Net/wwwroot/css/chess/spectate.css index cb29072..d47db37 100644 --- a/JoshHeaps.Net/wwwroot/css/chess/spectate.css +++ b/JoshHeaps.Net/wwwroot/css/chess/spectate.css @@ -79,6 +79,21 @@ html, body { 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; } diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js index 6262164..e935454 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js @@ -14,6 +14,12 @@ const ChessAPI = { 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", @@ -115,6 +121,7 @@ const ChessAPI = { } if (gameOver) { + showCopyPgn(GameState.currentGameId); await ChessSignalR.leaveGame(); } }, 500); diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js index 68f35cc..de0ef5b 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js @@ -46,6 +46,7 @@ const ChessSignalR = { if (reason === "forfeit") alert(youWon ? "🏳️ Your opponent forfeited — you win!" : "🏳️ You forfeited this game."); + showCopyPgn(gameId); await this.leaveGame(); }, diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js index c4fea0b..96debc7 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js @@ -1,6 +1,7 @@ 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"], @@ -106,6 +107,7 @@ const Spectate = { async removeGame(gameId) { this.games.delete(gameId); + this.pgns.delete(gameId); document.getElementById(`card-${gameId}`)?.remove(); await this.connection.invoke("LeaveWebsocketGroup", gameId).catch(() => { }); }, @@ -207,6 +209,7 @@ const Spectate = { if (!text) { banner?.remove(); + card.querySelector(".copyPgnBtn")?.remove(); card.classList.remove("over"); return; } @@ -219,6 +222,47 @@ const Spectate = { 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) { diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js index 9bfda72..6cdac8b 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js @@ -1,3 +1,35 @@ +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"); @@ -14,6 +46,7 @@ async function forfeitCurrentGame() { async function startNewGame() { await ChessSignalR.stopConnection(); await forfeitCurrentGame(); + resetCopyPgn(); try { const gameData = await ChessAPI.joinGame(); @@ -40,6 +73,7 @@ async function startNewGame() { async function startCPUGame() { await ChessSignalR.stopConnection(); await forfeitCurrentGame(); + resetCopyPgn(); try { const difficulty = await ChessModals.promptDifficulty();