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();