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.Hubs;
|
||||||
using JoshHeaps.Net.Models;
|
using JoshHeaps.Net.Models;
|
||||||
|
using JoshHeaps.Net.Services.Implementations;
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
@@ -280,6 +281,18 @@ public class ChessController(
|
|||||||
return Ok();
|
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>
|
/// <summary>
|
||||||
/// Get the legal moves for a specific piece in a specific game.
|
/// Get the legal moves for a specific piece in a specific game.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ public class GameState
|
|||||||
// Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection.
|
// Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection.
|
||||||
public List<string> PositionHistory { get; set; }
|
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
|
// 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.
|
// us how many trailing PositionHistory entries belong to the current repetition window.
|
||||||
public int HalfmoveClock { get; set; }
|
public int HalfmoveClock { get; set; }
|
||||||
@@ -68,5 +71,6 @@ public class GameState
|
|||||||
Pieces = [];
|
Pieces = [];
|
||||||
MoveHistory = [];
|
MoveHistory = [];
|
||||||
PositionHistory = [];
|
PositionHistory = [];
|
||||||
|
SanHistory = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<div id="buttonContainer" class="sideContent">
|
<div id="buttonContainer" class="sideContent">
|
||||||
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
|
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
|
||||||
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</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>
|
<a id="watchLink" href="/watch">Watch other games →</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class ChessService : IChessService
|
|||||||
|
|
||||||
gameState.Pieces.Clear();
|
gameState.Pieces.Clear();
|
||||||
gameState.MoveHistory.Clear();
|
gameState.MoveHistory.Clear();
|
||||||
|
gameState.SanHistory.Clear();
|
||||||
|
|
||||||
gameState.WhiteCanCastleKingside = true;
|
gameState.WhiteCanCastleKingside = true;
|
||||||
gameState.WhiteCanCastleQueenside = true;
|
gameState.WhiteCanCastleQueenside = true;
|
||||||
@@ -141,12 +142,17 @@ public class ChessService : IChessService
|
|||||||
if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol))
|
if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol))
|
||||||
return new MoveResultDto { Success = false, Message = "Illegal move." };
|
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);
|
PerformMove(gameState, piece, targetPos, moveDto);
|
||||||
|
|
||||||
UpdateCheckStatus(gameState);
|
UpdateCheckStatus(gameState);
|
||||||
|
|
||||||
var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
|
var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
|
||||||
gameState.MoveHistory.Add(notation);
|
gameState.MoveHistory.Add(notation);
|
||||||
|
gameState.SanHistory.Add(sanBase + (gameState.IsCheckmate ? "#" : gameState.IsCheck ? "+" : ""));
|
||||||
|
|
||||||
var positionKey = PositionKey(gameState);
|
var positionKey = PositionKey(gameState);
|
||||||
gameState.PositionHistory.Add(positionKey);
|
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>
|
/// <summary>
|
||||||
/// The repetition signature of a position: the first four FEN fields — piece placement,
|
/// 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.
|
/// 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;
|
margin: 2vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#copyPgnBtn {
|
||||||
|
background-color: #8cd5ed;
|
||||||
|
color: #262626;
|
||||||
|
border-radius: 30px;
|
||||||
|
border: 0px;
|
||||||
|
cursor: pointer;
|
||||||
|
order: 1;
|
||||||
|
padding: 2vh;
|
||||||
|
margin: 2vh;
|
||||||
|
}
|
||||||
|
|
||||||
#watchLink {
|
#watchLink {
|
||||||
color: #8cd5ed;
|
color: #8cd5ed;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|||||||
@@ -79,6 +79,21 @@ html, body {
|
|||||||
margin-top: 0.75rem;
|
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 {
|
.backButton {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ const ChessAPI = {
|
|||||||
return await response.json();
|
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) {
|
async forfeit(gameId, playerId) {
|
||||||
await fetch("/api/chess/forfeit", {
|
await fetch("/api/chess/forfeit", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -115,6 +121,7 @@ const ChessAPI = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (gameOver) {
|
if (gameOver) {
|
||||||
|
showCopyPgn(GameState.currentGameId);
|
||||||
await ChessSignalR.leaveGame();
|
await ChessSignalR.leaveGame();
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const ChessSignalR = {
|
|||||||
if (reason === "forfeit")
|
if (reason === "forfeit")
|
||||||
alert(youWon ? "🏳️ Your opponent forfeited — you win!" : "🏳️ You forfeited this game.");
|
alert(youWon ? "🏳️ Your opponent forfeited — you win!" : "🏳️ You forfeited this game.");
|
||||||
|
|
||||||
|
showCopyPgn(gameId);
|
||||||
await this.leaveGame();
|
await this.leaveGame();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const Spectate = {
|
const Spectate = {
|
||||||
connection: null,
|
connection: null,
|
||||||
games: new Map(), // gameId -> { isVsComputer, isComputerVsComputer, result }
|
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"],
|
pieceTypeNames: ["Pawn", "Rook", "Knight", "Bishop", "Queen", "King"],
|
||||||
|
|
||||||
@@ -106,6 +107,7 @@ const Spectate = {
|
|||||||
|
|
||||||
async removeGame(gameId) {
|
async removeGame(gameId) {
|
||||||
this.games.delete(gameId);
|
this.games.delete(gameId);
|
||||||
|
this.pgns.delete(gameId);
|
||||||
document.getElementById(`card-${gameId}`)?.remove();
|
document.getElementById(`card-${gameId}`)?.remove();
|
||||||
await this.connection.invoke("LeaveWebsocketGroup", gameId).catch(() => { });
|
await this.connection.invoke("LeaveWebsocketGroup", gameId).catch(() => { });
|
||||||
},
|
},
|
||||||
@@ -207,6 +209,7 @@ const Spectate = {
|
|||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
banner?.remove();
|
banner?.remove();
|
||||||
|
card.querySelector(".copyPgnBtn")?.remove();
|
||||||
card.classList.remove("over");
|
card.classList.remove("over");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -219,6 +222,47 @@ const Spectate = {
|
|||||||
|
|
||||||
banner.textContent = text;
|
banner.textContent = text;
|
||||||
card.classList.add("over");
|
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) {
|
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() {
|
async function forfeitCurrentGame() {
|
||||||
const gameId = ChessUtils.getCookie("chessGameId");
|
const gameId = ChessUtils.getCookie("chessGameId");
|
||||||
const playerId = ChessUtils.getCookie("chessPlayerId");
|
const playerId = ChessUtils.getCookie("chessPlayerId");
|
||||||
@@ -14,6 +46,7 @@ async function forfeitCurrentGame() {
|
|||||||
async function startNewGame() {
|
async function startNewGame() {
|
||||||
await ChessSignalR.stopConnection();
|
await ChessSignalR.stopConnection();
|
||||||
await forfeitCurrentGame();
|
await forfeitCurrentGame();
|
||||||
|
resetCopyPgn();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const gameData = await ChessAPI.joinGame();
|
const gameData = await ChessAPI.joinGame();
|
||||||
@@ -40,6 +73,7 @@ async function startNewGame() {
|
|||||||
async function startCPUGame() {
|
async function startCPUGame() {
|
||||||
await ChessSignalR.stopConnection();
|
await ChessSignalR.stopConnection();
|
||||||
await forfeitCurrentGame();
|
await forfeitCurrentGame();
|
||||||
|
resetCopyPgn();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const difficulty = await ChessModals.promptDifficulty();
|
const difficulty = await ChessModals.promptDifficulty();
|
||||||
|
|||||||
Reference in New Issue
Block a user