Feature/custom chess engine #2

Closed
jheaps wants to merge 17 commits from feature/customChessEngine into AddProject
11 changed files with 255 additions and 0 deletions
Showing only changes of commit b651683e63 - Show all commits
@@ -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>
+4
View File
@@ -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 = [];
} }
} }
+1
View File
@@ -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 "*";
}
}
+11
View File
@@ -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();