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