diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index 74b3b96..b19b3a0 100644 --- a/JoshHeaps.Net/Controllers/ChessController.cs +++ b/JoshHeaps.Net/Controllers/ChessController.cs @@ -1,16 +1,16 @@ -using JoshHeaps.Net.Models; +using JoshHeaps.Net.Hubs; +using JoshHeaps.Net.Models; using JoshHeaps.Net.Services.Interfaces; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; using System.Collections.Concurrent; namespace JoshHeaps.Net.Controllers; [ApiController] [Route("api/[controller]")] -public class ChessController : ControllerBase +public class ChessController(IChessService chessService, IHubContext chessHub, IBackgroundTaskQueue queue) : ControllerBase { - private readonly IChessService chessService; - /// /// Store of ongoing games. /// @@ -18,21 +18,50 @@ public class ChessController : ControllerBase private static ConcurrentDictionary _gameRemovalTasks = []; - public ChessController(IChessService chessService) - { - this.chessService = chessService; - } - /// /// Create a new chess game and store it in-memory. /// - [HttpPost("new")] - public ActionResult CreateGame() + [HttpGet("new")] + [HttpGet("new/{difficulty}")] + public ActionResult CreateGame(int difficulty = 20) { var gameState = chessService.CreateNewGame(); _games[gameState.GameId] = gameState; - return Ok(new { gameState.GameId }); + gameState.IsVsComputer = true; + gameState.WhiteJoined = true; + gameState.BlackJoined = true; + Guid playerId = Guid.NewGuid(); + Guid computerId = Guid.NewGuid(); + var isWhite = Random.Shared.Next(2) == 0; + + gameState.Computer = new(difficulty); + + if (isWhite) + { + gameState.WhitePlayerId = playerId; + gameState.BlackPlayerId = computerId; + } + else + { + gameState.WhitePlayerId = computerId; + gameState.BlackPlayerId = playerId; + queue.Queue(async () => + { + // Give user's browser time to connect to signalR and such. + await Task.Delay(TimeSpan.FromSeconds(1)); + await gameState.Computer.MakeMove(gameState, chessHub, chessService); + }); + } + + ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1)); + + return Ok(new + { + Id = playerId, + IsWhite = isWhite, + gameState.GameId + }); } /// @@ -153,9 +182,15 @@ public class ChessController : ControllerBase else { // increase timeout if play continues. - ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1)); + if (gameState.IsVsComputer) + ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1)); + else + ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1)); } + if (gameState.IsVsComputer && gameState.Computer is not null) + queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService)); + return Ok(result); } @@ -199,6 +234,10 @@ public class ChessController : ControllerBase _gameRemovalTasks[id] = Task.Run(async () => { await Task.Delay(delay); + + if (_games[id].Computer is not null) + await _games[id].Computer!.DisposeAsync(); + _games.Remove(id, out _); _gameRemovalTasks.Remove(id, out _); }); @@ -209,6 +248,10 @@ public class ChessController : ControllerBase _gameRemovalTasks.TryAdd(id, Task.Run(async () => { await Task.Delay(delay); + + if (_games[id].Computer is not null) + await _games[id].Computer!.DisposeAsync(); + _games.Remove(id, out _); _gameRemovalTasks.Remove(id, out _); })); diff --git a/JoshHeaps.Net/Models/GameState.cs b/JoshHeaps.Net/Models/GameState.cs index 2516c93..6e8f468 100644 --- a/JoshHeaps.Net/Models/GameState.cs +++ b/JoshHeaps.Net/Models/GameState.cs @@ -1,4 +1,6 @@ -namespace JoshHeaps.Net.Models; +using JoshHeaps.Net.Services.Implementations; + +namespace JoshHeaps.Net.Models; public class GameState { @@ -39,6 +41,10 @@ public class GameState public Guid WhitePlayerId { get; set; } public Guid BlackPlayerId { get; set; } + public bool IsVsComputer { get; set; } = false; + + public Stockfish? Computer { get; set; } + // optional: convenience public bool IsOpen => !WhiteJoined || !BlackJoined; diff --git a/JoshHeaps.Net/Pages/Chess.cshtml b/JoshHeaps.Net/Pages/Chess.cshtml index fdfaad6..5b00402 100644 --- a/JoshHeaps.Net/Pages/Chess.cshtml +++ b/JoshHeaps.Net/Pages/Chess.cshtml @@ -17,13 +17,13 @@
-

Chess Arena

-

This page will host your chess UI and interactions.

+

Chess

+

Click a button to start a game :)

- +
@@ -45,6 +45,18 @@ + + @section Scripts { diff --git a/JoshHeaps.Net/Program.cs b/JoshHeaps.Net/Program.cs index 9142455..c430c45 100644 --- a/JoshHeaps.Net/Program.cs +++ b/JoshHeaps.Net/Program.cs @@ -26,6 +26,7 @@ public class Program builder.Services.AddSignalR(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); var app = builder.Build(); diff --git a/JoshHeaps.Net/Services/Implementations/BackgroundTaskQueue.cs b/JoshHeaps.Net/Services/Implementations/BackgroundTaskQueue.cs new file mode 100644 index 0000000..deffb9d --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/BackgroundTaskQueue.cs @@ -0,0 +1,21 @@ +using JoshHeaps.Net.Services.Interfaces; +using System.Collections.Concurrent; +using System.Threading.Channels; + +namespace JoshHeaps.Net.Services.Implementations; + +public class BackgroundTaskQueue : IBackgroundTaskQueue +{ + private readonly ConcurrentDictionary _runningTasks = new(); + + public void Queue(Func workItem) + { + var task = Task.Run(workItem); + _runningTasks.TryAdd(task.Id, task); + + task.ContinueWith(t => _runningTasks.TryRemove(t.Id, out _), TaskScheduler.Default); + } + + public IReadOnlyCollection Running => [.. _runningTasks.Values]; + public Task WhenAllDone() => Task.WhenAll(Running); +} diff --git a/JoshHeaps.Net/Services/Implementations/Stockfish.cs b/JoshHeaps.Net/Services/Implementations/Stockfish.cs index c235e96..2cb5a8d 100644 --- a/JoshHeaps.Net/Services/Implementations/Stockfish.cs +++ b/JoshHeaps.Net/Services/Implementations/Stockfish.cs @@ -1,4 +1,7 @@ -using JoshHeaps.Net.Models; +using JoshHeaps.Net.Hubs; +using JoshHeaps.Net.Models; +using JoshHeaps.Net.Services.Interfaces; +using Microsoft.AspNetCore.SignalR; using System.Diagnostics; using System.Reflection; using System.Text; @@ -11,13 +14,15 @@ public sealed class Stockfish : IAsyncDisposable private readonly Process _p; private readonly StreamWriter _stdin; private readonly Channel _stdout = Channel.CreateUnbounded(); + private readonly int _skill; public Stockfish(int skill = 20, int hash = 256) { - string relativeFilePath = @"JoshHeaps.Net\Resources\stockfish-windows-x86-64-avx2.exe"; - string exePath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)! - .Split("JoshHeaps.Net") - .First() + relativeFilePath); + _skill = skill; + string relativeFilePath = @"\Resources\stockfish-windows-x86-64-avx2.exe"; + string exePath = Assembly.GetExecutingAssembly().Location.Split(@"\bin\")[0] + relativeFilePath; + + Console.Write(exePath); _p = new Process { @@ -50,10 +55,10 @@ public sealed class Stockfish : IAsyncDisposable WaitFor("readyok").GetAwaiter().GetResult(); } - public async Task GetBestMoveAsync(string fen, int millis = 1000) + public async Task GetBestMoveAsync(string fen) { Send($"position fen {fen}"); - Send($"go movetime {millis}"); + Send($"go depth {_skill}"); string? best = null; await foreach (var line in _stdout.Reader.ReadAllAsync()) @@ -82,32 +87,75 @@ public sealed class Stockfish : IAsyncDisposable await _p.WaitForExitAsync(); _p.Dispose(); } + + public async Task MakeMove(GameState state, IHubContext chessHub, IChessService chessService) + { + var move = await GetBestMoveAsync(state.ToFen()); + + var moveDto = move.ToMoveDto( + state, + state.CurrentPlayer == PieceColor.White + ? state.WhitePlayerId + : state.BlackPlayerId); + + var result = chessService.MakeMove(state, moveDto); + + await chessHub.Clients.Group(state.GameId.ToString()).SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), moveDto, result); + } } public static class StockfishHelpers { + public static MoveDto ToMoveDto( + this string uci, + GameState gameState, + Guid playerId) + { + int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1'); + int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1'); + + var piece = gameState.Board[fRow, fCol] + ?? throw new Exception("No piece at source square"); + + PieceType? promo = uci.Length == 5 ? uci[4] switch + { + 'q' => PieceType.Queen, + 'r' => PieceType.Rook, + 'b' => PieceType.Bishop, + 'n' => PieceType.Knight, + _ => null + } : null; + + return new MoveDto + { + GameId = gameState.GameId, + PlayerId = playerId, + PieceId = piece.Id, + TargetRow = tRow, + TargetCol = tCol, + PromotionChoice = promo, + SourceCol = fCol, + SourceRow = fRow, + }; + } + /// /// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string. /// Only piece placement + active colour + castling are computed; the rest use /// safe defaults (-, 0, 1). That is all Stockfish needs. /// - public static string ToFen( - ChessPiece?[,] board, - PieceColor activeColour = PieceColor.White) + public static string ToFen(this GameState gs) { - if (board.GetLength(0) != 8 || board.GetLength(1) != 8) - throw new ArgumentException("Board must be 8×8."); - var sb = new StringBuilder(64); - // ----- 1) piece placement ----- - for (int rank = 0; rank < 8; rank++) + /* 1) piece placement */ + for (int row = 0; row < 8; row++) { int empty = 0; - for (int file = 0; file < 8; file++) + for (int col = 0; col < 8; col++) { - var p = board[rank, file]; + var p = gs.Board[row, col]; if (p is null) { @@ -116,28 +164,54 @@ public static class StockfishHelpers else { if (empty > 0) { sb.Append(empty); empty = 0; } - sb.Append(ToFenChar(p)); + sb.Append(ToFenChar(p)); // ← unchanged helper } } if (empty > 0) sb.Append(empty); - if (rank < 7) sb.Append('/'); + if (row < 7) sb.Append('/'); } - // ----- 2) active colour ----- - sb.Append(activeColour == PieceColor.White ? " w " : " b "); + /* 2) active colour */ + sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b "); - // ----- 3) castling rights (simple check of corner rooks + kings) ----- - sb.Append(GetCastlingFlags(board)); + /* 3) castling rights (from GameState flags) */ + sb.Append(GetCastlingFlags(gs)); - // ----- 4-6) en-passant, half-move, full-move ----- - sb.Append(" - 0 1"); // en-passant target; clocks + /* 4) en-passant target square */ + sb.Append(' '); + sb.Append(gs.EnPassantTarget.HasValue + ? Alg(gs.EnPassantTarget.Value) + : "-"); + + /* 5-6) half-move clock + full-move number */ + int fullMoves = gs.MoveHistory.Count / 2 + 1; + sb.Append(" 0 ").Append(fullMoves); return sb.ToString(); } /* ---------- helpers ---------- */ + private static string GetCastlingFlags(GameState gs) + { + var flags = new StringBuilder(4); + + if (gs.WhiteCanCastleKingside) flags.Append('K'); + if (gs.WhiteCanCastleQueenside) flags.Append('Q'); + if (gs.BlackCanCastleKingside) flags.Append('k'); + if (gs.BlackCanCastleQueenside) flags.Append('q'); + + return flags.Length == 0 ? "-" : flags.ToString(); + } + + private static string Alg(Position p) + { + char file = (char)('a' + p.Col); + int rank = 8 - p.Row; + return $"{file}{rank}"; + } + private static char ToFenChar(ChessPiece p) => p switch { { Type: PieceType.Pawn, Color: PieceColor.White } => 'P', @@ -154,26 +228,4 @@ public static class StockfishHelpers { Type: PieceType.King, Color: PieceColor.Black } => 'k', _ => throw new ArgumentOutOfRangeException(nameof(p)) }; - - private static string GetCastlingFlags(ChessPiece?[,] b) - { - // Fast lookup helpers - ChessPiece? A1 = b[7, 0], H1 = b[7, 7], E1 = b[7, 4]; - ChessPiece? A8 = b[0, 0], H8 = b[0, 7], E8 = b[0, 4]; - - var flags = new StringBuilder(4); - - if (E1 is { Type: PieceType.King, Color: PieceColor.White, HasMoved: false }) - { - if (H1 is { Type: PieceType.Rook, Color: PieceColor.White, HasMoved: false }) flags.Append('K'); - if (A1 is { Type: PieceType.Rook, Color: PieceColor.White, HasMoved: false }) flags.Append('Q'); - } - if (E8 is { Type: PieceType.King, Color: PieceColor.Black, HasMoved: false }) - { - if (H8 is { Type: PieceType.Rook, Color: PieceColor.Black, HasMoved: false }) flags.Append('k'); - if (A8 is { Type: PieceType.Rook, Color: PieceColor.Black, HasMoved: false }) flags.Append('q'); - } - - return flags.Length == 0 ? "-" : flags.ToString(); - } } \ No newline at end of file diff --git a/JoshHeaps.Net/Services/Interfaces/IBackgroundTaskQueue.cs b/JoshHeaps.Net/Services/Interfaces/IBackgroundTaskQueue.cs new file mode 100644 index 0000000..7dc94cd --- /dev/null +++ b/JoshHeaps.Net/Services/Interfaces/IBackgroundTaskQueue.cs @@ -0,0 +1,6 @@ +namespace JoshHeaps.Net.Services.Interfaces; + +public interface IBackgroundTaskQueue +{ + void Queue(Func workItem); +} diff --git a/JoshHeaps.Net/wwwroot/css/chess/game.css b/JoshHeaps.Net/wwwroot/css/chess/game.css index 5668292..203a487 100644 --- a/JoshHeaps.Net/wwwroot/css/chess/game.css +++ b/JoshHeaps.Net/wwwroot/css/chess/game.css @@ -14,11 +14,11 @@ } .chessSquare.light { - background-color: #ccc; + background: #ccc; } .chessSquare.dark { - background-color: #656770; + background: #656770; } .chessPiece { @@ -49,16 +49,20 @@ transform: rotate(180deg); } -.chessSquare.previous-start { - box-sizing: border-box; - border: 4px solid #ffd700; /* gold or whatever you like */ - z-index: 1; +.chessSquare.light.previous-start { + background: linear-gradient(rgba(0, 88, 171, 0.3), rgba(0, 88, 171, 0.3)), #ccc; } -.chessSquare.previous-end { - box-sizing: border-box; - border: 4px solid #ff8c00; /* orange */ - z-index: 1; +.chessSquare.light.previous-end { + background: linear-gradient(rgba(0, 88, 171, 0.3), rgba(0, 88, 171, 0.3)), #ccc; +} + +.chessSquare.dark.previous-start { + background: linear-gradient(rgba(0, 88, 171, 0.3), rgba(0, 88, 171, 0.3)), #656770; +} + +.chessSquare.dark.previous-end { + background: linear-gradient(rgba(0, 88, 171, 0.3), rgba(0, 88, 171, 0.3)), #656770; } #promotionModal { @@ -106,4 +110,52 @@ width: 50px; height: 50px; pointer-events: none; /* ensures img doesn't steal the click */ +} + +#difficultyModal { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: #1e1e1e; + padding: 20px; + border-radius: 10px; + box-shadow: 0 0 20px rgba(0,0,0,0.6); + color: white; + z-index: 1000; + text-align: center; +} + +#difficultyModal p { + margin-bottom: 15px; + font-size: 18px; + font-weight: bold; +} + +#difficultyButtonContainer { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; + justify-content: center; +} + +#difficultyButtonContainer button { + background-color: #2c2c2c; + border: none; + padding: 10px; + border-radius: 8px; + cursor: pointer; + transition: transform 0.2s ease; + color: white; +} + +#difficultyButtonContainer button:hover { + transform: scale(1.1); + background-color: #3a3a3a; +} + +#difficultyButtonContainer img { + width: 50px; + height: 50px; + pointer-events: none; /* ensures img doesn't steal the click */ } \ No newline at end of file diff --git a/JoshHeaps.Net/wwwroot/css/chess/site.css b/JoshHeaps.Net/wwwroot/css/chess/site.css index ff1f9bd..c0bee9f 100644 --- a/JoshHeaps.Net/wwwroot/css/chess/site.css +++ b/JoshHeaps.Net/wwwroot/css/chess/site.css @@ -14,6 +14,18 @@ cursor: pointer; order: 1; padding: 2vh; + margin: 2vh; +} + +#startCPUGame { + background-color: #8cd5ed; + color: #262626; + border-radius: 30px; + border: 0px; + cursor: pointer; + order: 1; + padding: 2vh; + margin: 2vh; } #chessContainer { @@ -50,7 +62,8 @@ flex-direction: column; order: -1; flex-shrink: 1; - font-size: large + font-size: large; + margin: 5vw; } #buttonContainer { @@ -105,10 +118,20 @@ font-size: large; } + #startCPUGame { + padding: 1vw; + margin: 1vw; + font-size: large; + } + #chessBoard { aspect-ratio: 1 / 1; flex-shrink: 1; width: min(90vh, 90vw); max-height: 90vh; } + + #difficultyButtonContainer { + grid-template-columns: repeat(10, 1fr); + } } \ No newline at end of file diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/chessLogic.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/chessLogic.js index 260c1c3..cc7c198 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/chessLogic.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/chessLogic.js @@ -38,6 +38,38 @@ async function startNewGame() { renderPieces(data.pieces); } +async function startCPUGame() { + if (signalRConnection) { + await signalRConnection.stop(); + signalRConnection = null; + } + + let difficulty = await promptDifficulty(); + + // Join the game via API + const response = await fetch(`/api/chess/new/${difficulty}`); + const gameData = await response.json(); + + currentGameId = gameData.gameId; + currentPlayerId = gameData.id; + currentPlayerIsWhite = gameData.isWhite; + previousMoveStart = null; + previousMoveEnd = null; + document.cookie = `chessGameId=${currentGameId}; path=/; max-age=86400`; // expires in 1 day + document.cookie = `chessPlayerId=${currentPlayerId}; path=/; max-age=86400`; + document.cookie = `chessPlayerIsWhite=${currentPlayerIsWhite}; path=/; max-age=86400` + console.log("🆕 Game started:", currentGameId); + + // Build and start SignalR connection + await setupSignalRConnection(); + + // Render initial state + const gameState = await fetch(`/api/chess/${currentGameId}`); + const data = await gameState.json(); + + renderPieces(data.pieces); +} + function renderPieces(pieces) { // Clear all squares for (let i = 0; i < 64; i++) { @@ -58,6 +90,28 @@ function renderPieces(pieces) { img.alt = piece.type; img.classList.add("chessPiece"); + img.draggable = true; + img.ondragstart = async (e) => { + selectedPiece = piece; + try { + const res = await fetch(`/api/chess/${currentGameId}/legalMoves/${piece.id}`); + if (!res.ok) throw new Error("API failed"); + legalMoves = await res.json(); + highlightSelected(piece.row, piece.col); + highlightLegalMoves(legalMoves); + } + catch (err) { + console.error(err); + } + + e.dataTransfer.setData("text/plain", JSON.stringify({ + srcRow: piece.Row, + srcCol: piece.Col + })) + } + + img.ondragend = clearHighlights; + img.onclick = (e) => { e.stopPropagation(); // 👈 Prevents the parent square click from firing handlePieceClick(piece); @@ -310,6 +364,16 @@ function promptPromotion() { }); } +function promptDifficulty() { + return new Promise(resolve => { + document.getElementById("difficultyModal").style.display = "block"; + window.selectDifficulty = (difficulty) => { + document.getElementById("difficultyModal").style.display = "none"; + resolve(difficulty); + }; + }); +} + function updatePromotionModalImages(color) { const pieceNames = ["Queen", "Rook", "Bishop", "Knight"]; const buttons = document.querySelectorAll("#promotionModal button img"); @@ -346,4 +410,30 @@ window.addEventListener('load', async () => { console.error("Failed to rejoin saved game:", err); } } +}); + +document.addEventListener('DOMContentLoaded', () => { + // add near the bottom of chessLogic.js, run once after the DOM is ready + for (let i = 0; i < 64; i++) { + const square = document.getElementById(`square-${i}`); + + // Allow dropping by cancelling the default + square.ondragover = (e) => { + e.preventDefault(); + }; + + square.ondrop = (e) => { + e.preventDefault(); + + // If no piece is being dragged, ignore + if (!selectedPiece) return; + + // Board-space index to (row,col) + const index = parseInt(square.id.split('-')[1], 10); + const targetRow = Math.floor(index / 8); + const targetCol = index % 8; + + handleMove(targetRow, targetCol); + }; + } }); \ No newline at end of file