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:
co-authored by
Claude Opus 4.8
parent
d87cea572f
commit
b651683e63
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export a game as PGN (works while the game is still in memory after it ends).
|
||||
/// </summary>
|
||||
[HttpGet("{gameId}/pgn")]
|
||||
public ActionResult GetPgn(Guid gameId)
|
||||
{
|
||||
if (!_games.TryGetValue(gameId, out var gameState))
|
||||
return NotFound("Game not found");
|
||||
|
||||
return Content(gameState.ToPgn(), "application/x-chess-pgn");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the legal moves for a specific piece in a specific game.
|
||||
/// </summary>
|
||||
|
||||
@@ -40,6 +40,9 @@ public class GameState
|
||||
// Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection.
|
||||
public List<string> PositionHistory { get; set; }
|
||||
|
||||
// Moves in standard algebraic notation (SAN), in order, for PGN export.
|
||||
public List<string> SanHistory { get; set; }
|
||||
|
||||
// Half-moves since the last capture or pawn move (the FEN 50-move clock). Also tells
|
||||
// us how many trailing PositionHistory entries belong to the current repetition window.
|
||||
public int HalfmoveClock { get; set; }
|
||||
@@ -68,5 +71,6 @@ public class GameState
|
||||
Pieces = [];
|
||||
MoveHistory = [];
|
||||
PositionHistory = [];
|
||||
SanHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<div id="buttonContainer" class="sideContent">
|
||||
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
|
||||
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button>
|
||||
<button id="copyPgnBtn" onclick="copyPgn()" style="display: none">Copy PGN</button>
|
||||
<a id="watchLink" href="/watch">Watch other games →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the standard-algebraic notation for a move from the position BEFORE it is
|
||||
/// applied (the check/mate suffix is added by the caller afterward).
|
||||
/// </summary>
|
||||
private string BuildSan(GameState gs, ChessPiece piece, Position target, MoveDto moveDto)
|
||||
{
|
||||
var from = piece.Position;
|
||||
|
||||
if (piece.Type == PieceType.King && Math.Abs(target.Col - from.Col) == 2)
|
||||
return target.Col > from.Col ? "O-O" : "O-O-O";
|
||||
|
||||
bool targetOccupied = gs.Board[target.Row, target.Col] != null;
|
||||
bool isEnPassant = piece.Type == PieceType.Pawn && from.Col != target.Col && !targetOccupied;
|
||||
bool isCapture = targetOccupied || isEnPassant;
|
||||
string dest = SquareName(target);
|
||||
|
||||
if (piece.Type == PieceType.Pawn)
|
||||
{
|
||||
var san = isCapture ? $"{FileChar(from.Col)}x{dest}" : dest;
|
||||
|
||||
bool promotes = (piece.Color == PieceColor.White && target.Row == 0)
|
||||
|| (piece.Color == PieceColor.Black && target.Row == 7);
|
||||
|
||||
if (promotes)
|
||||
san += "=" + PieceLetter(moveDto.PromotionChoice ?? PieceType.Queen);
|
||||
|
||||
return san;
|
||||
}
|
||||
|
||||
return $"{PieceLetter(piece.Type)}{Disambiguation(gs, piece, target)}{(isCapture ? "x" : "")}{dest}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SAN disambiguation: when another piece of the same type and color can also reach the
|
||||
/// target, qualify the origin by file, else rank, else both.
|
||||
/// </summary>
|
||||
private string Disambiguation(GameState gs, ChessPiece piece, Position target)
|
||||
{
|
||||
var rivals = gs.Pieces
|
||||
.Where(p => p.Id != piece.Id && p.Type == piece.Type && p.Color == piece.Color && p.Position.Row >= 0)
|
||||
.Where(p => GetLegalMovesForPiece(gs, p.Id).Any(m => m.Row == target.Row && m.Col == target.Col))
|
||||
.ToList();
|
||||
|
||||
if (rivals.Count == 0)
|
||||
return "";
|
||||
|
||||
if (rivals.All(p => p.Position.Col != piece.Position.Col))
|
||||
return FileChar(piece.Position.Col).ToString();
|
||||
|
||||
if (rivals.All(p => p.Position.Row != piece.Position.Row))
|
||||
return (8 - piece.Position.Row).ToString();
|
||||
|
||||
return $"{FileChar(piece.Position.Col)}{8 - piece.Position.Row}";
|
||||
}
|
||||
|
||||
private static char FileChar(int col) => (char)('a' + col);
|
||||
|
||||
private static string SquareName(Position p) => $"{FileChar(p.Col)}{8 - p.Row}";
|
||||
|
||||
private static string PieceLetter(PieceType type) => type switch
|
||||
{
|
||||
PieceType.Knight => "N",
|
||||
PieceType.Bishop => "B",
|
||||
PieceType.Rook => "R",
|
||||
PieceType.Queen => "Q",
|
||||
PieceType.King => "K",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The repetition signature of a position: the first four FEN fields — piece placement,
|
||||
/// side to move, castling rights, and en-passant target. Move counters are excluded.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using JoshHeaps.Net.Models;
|
||||
using System.Text;
|
||||
|
||||
namespace JoshHeaps.Net.Services.Implementations;
|
||||
|
||||
public static class PgnExporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Render a game as PGN: the standard seven-tag header plus the SAN movetext and result.
|
||||
/// </summary>
|
||||
public static string ToPgn(this GameState gs)
|
||||
{
|
||||
var result = ResultTag(gs);
|
||||
var name = gs.IsComputerVsComputer ? "Computer" : null;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("[Event \"JoshHeaps.Net Chess\"]");
|
||||
sb.AppendLine("[Site \"joshheaps.net\"]");
|
||||
sb.AppendLine($"[Date \"{DateTime.Now:yyyy.MM.dd}\"]");
|
||||
sb.AppendLine($"[White \"{name ?? "White"}\"]");
|
||||
sb.AppendLine($"[Black \"{name ?? "Black"}\"]");
|
||||
sb.AppendLine($"[Result \"{result}\"]");
|
||||
sb.AppendLine();
|
||||
|
||||
for (int i = 0; i < gs.SanHistory.Count; i++)
|
||||
{
|
||||
if (i % 2 == 0)
|
||||
sb.Append(i / 2 + 1).Append(". ");
|
||||
|
||||
sb.Append(gs.SanHistory[i]).Append(' ');
|
||||
}
|
||||
|
||||
sb.Append(result);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ResultTag(GameState gs)
|
||||
{
|
||||
if (gs.IsCheckmate)
|
||||
return gs.CurrentPlayer == PieceColor.White ? "0-1" : "1-0";
|
||||
|
||||
if (gs.IsStalemate || gs.IsThreefoldRepetition)
|
||||
return "1/2-1/2";
|
||||
|
||||
if (gs.IsForfeited)
|
||||
return gs.Winner == PieceColor.White ? "1-0" : "0-1";
|
||||
|
||||
return "*";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user