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) <[email protected]>
This commit is contained in:
Josh-Heaps
2026-06-08 19:14:39 -06:00
co-authored by Claude Opus 4.8
parent d87cea572f
commit b651683e63
11 changed files with 255 additions and 0 deletions
@@ -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);
@@ -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();
},
@@ -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) {
@@ -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();