diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index 29bf494..54e9f2e 100644 --- a/JoshHeaps.Net/Controllers/ChessController.cs +++ b/JoshHeaps.Net/Controllers/ChessController.cs @@ -1,6 +1,8 @@ -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; @@ -11,7 +13,8 @@ public class ChessController( IChessService chessService, IBackgroundTaskQueue queue, IChessEngineFactory engineFactory, - IComputerMoveOrchestrator orchestrator) : ControllerBase + IComputerMoveOrchestrator orchestrator, + IHubContext chessHub) : ControllerBase { /// /// Store of ongoing games. @@ -23,6 +26,8 @@ public class ChessController( private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1); private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1); private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1); + private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30); /// /// Create a new chess game and store it in-memory. @@ -70,6 +75,31 @@ public class ChessController( }); } + /// + /// Create a game the computer plays against itself and auto-play it move by move, + /// broadcasting each move so it can be watched on the spectator page. + /// + [HttpGet("watch/cpu")] + [HttpGet("watch/cpu/{difficulty}")] + public ActionResult CreateSelfPlayGame(int difficulty = 4) + { + var gameState = chessService.CreateNewGame(); + _games[gameState.GameId] = gameState; + + gameState.IsVsComputer = true; + gameState.IsComputerVsComputer = true; + gameState.WhiteJoined = true; + gameState.BlackJoined = true; + gameState.WhitePlayerId = Guid.NewGuid(); + gameState.BlackPlayerId = Guid.NewGuid(); + gameState.Computer = engineFactory.Create(difficulty); + + ScheduleRemoveGame(gameState.GameId, _computerGameTimeout); + StartSelfPlay(gameState); + + return Ok(new { gameState.GameId }); + } + /// /// Joins the "pool" of chess players. /// Test code expects to receive a GUID for the player @@ -112,6 +142,30 @@ public class ChessController( }); } + /// + /// List in-progress games for spectators: both sides present and the game not yet decided. + /// + [HttpGet("active")] + public ActionResult GetActiveGames() + { + var activeGames = _games.Values + // In-progress games, plus finished computer-vs-computer games still in their result window. + .Where(g => g.WhiteJoined && g.BlackJoined + && ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer)) + .Select(g => new + { + g.GameId, + g.IsVsComputer, + g.IsComputerVsComputer, + CurrentPlayer = g.CurrentPlayer.ToString(), + MoveCount = g.MoveHistory.Count, + g.IsCheck + }) + .OrderByDescending(g => g.MoveCount); + + return Ok(activeGames); + } + /// /// Get the state of an existing game by ID. /// @@ -128,6 +182,7 @@ public class ChessController( gameState.IsCheck, gameState.IsCheckmate, gameState.IsStalemate, + gameState.IsThreefoldRepetition, EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null, gameState.WhiteCanCastleKingside, gameState.WhiteCanCastleQueenside, @@ -180,19 +235,51 @@ public class ChessController( if (!result.Success) return BadRequest(result); - if (result.IsCheckmate || result.IsStalemate) + var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition; + + if (isGameOver) ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout); else if (gameState.IsVsComputer) ScheduleRemoveGame(gameState.GameId, _computerGameTimeout); else ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout); - if (gameState.IsVsComputer && gameState.Computer is not null) + if (!isGameOver && gameState.IsVsComputer && gameState.Computer is not null) queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!)); return Ok(result); } + /// + /// Forfeit a game on behalf of the calling player, handing the win to the opponent. + /// Used when a player abandons a game (e.g. starts a new one mid-game). + /// + [HttpPost("forfeit")] + public async Task Forfeit([FromBody] ForfeitDto forfeit) + { + if (!_games.TryGetValue(forfeit.GameId, out var gameState)) + return NotFound("Game not found"); + + if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited) + return Ok(); + + var isWhitePlayer = gameState.WhitePlayerId == forfeit.PlayerId; + var isBlackPlayer = gameState.BlackPlayerId == forfeit.PlayerId; + + if (!isWhitePlayer && !isBlackPlayer) + return StatusCode(403, "You are not a player in this game."); + + gameState.IsForfeited = true; + gameState.Winner = isWhitePlayer ? PieceColor.Black : PieceColor.White; + + await chessHub.Clients.Group(gameState.GameId.ToString()) + .SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit"); + + ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout); + + return Ok(); + } + /// /// Get the legal moves for a specific piece in a specific game. /// @@ -226,6 +313,42 @@ public class ChessController( return Ok(allMoves); } + /// + /// Drives a computer-vs-computer game: keeps asking the engine for the side-to-move's + /// move (which applies and broadcasts it) until the game ends or is removed. + /// + private void StartSelfPlay(GameState gameState) + { + queue.Queue(async () => + { + // Give spectators a moment to join the SignalR group before the first move. + await Task.Delay(TimeSpan.FromSeconds(1)); + + while (_games.ContainsKey(gameState.GameId) + && !gameState.IsCheckmate + && !gameState.IsStalemate + && !gameState.IsThreefoldRepetition + && !gameState.IsForfeited) + { + try + { + await orchestrator.PlayAsync(gameState, gameState.Computer!); + } + catch (Exception ex) + { + Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}"); + break; + } + + await Task.Delay(_selfPlayMoveDelay); + } + + // Leave the finished game in place briefly so spectators can see the result. + if (_games.ContainsKey(gameState.GameId)) + ScheduleRemoveGame(gameState.GameId, _selfPlayResultTimeout); + }); + } + private static void ScheduleRemoveGame(Guid id, TimeSpan delay) { if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts)) diff --git a/JoshHeaps.Net/Models/ForfeitDto.cs b/JoshHeaps.Net/Models/ForfeitDto.cs new file mode 100644 index 0000000..6fde214 --- /dev/null +++ b/JoshHeaps.Net/Models/ForfeitDto.cs @@ -0,0 +1,7 @@ +namespace JoshHeaps.Net.Models; + +public class ForfeitDto +{ + public Guid GameId { get; set; } + public Guid PlayerId { get; set; } +} diff --git a/JoshHeaps.Net/Models/GameState.cs b/JoshHeaps.Net/Models/GameState.cs index 63579f9..5781639 100644 --- a/JoshHeaps.Net/Models/GameState.cs +++ b/JoshHeaps.Net/Models/GameState.cs @@ -28,10 +28,18 @@ public class GameState public bool IsCheck { get; set; } public bool IsCheckmate { get; set; } public bool IsStalemate { get; set; } + public bool IsThreefoldRepetition { get; set; } + public bool IsForfeited { get; set; } = false; + + // The color that won, when the game ended by forfeit (null while the game is live). + public PieceColor? Winner { get; set; } // Keep a history of moves if desired public List MoveHistory { get; set; } + // Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection. + public List PositionHistory { get; set; } + // A list of all pieces to quickly reference them (optional but convenient). // Alternatively, you can iterate the Board array. public List Pieces { get; set; } @@ -42,6 +50,7 @@ public class GameState public Guid BlackPlayerId { get; set; } public bool IsVsComputer { get; set; } = false; + public bool IsComputerVsComputer { get; set; } = false; public IChessEngine? Computer { get; set; } @@ -54,5 +63,6 @@ public class GameState Board = new ChessPiece[8, 8]; Pieces = []; MoveHistory = []; + PositionHistory = []; } } diff --git a/JoshHeaps.Net/Models/MoveResultDto.cs b/JoshHeaps.Net/Models/MoveResultDto.cs index 81850f2..86d33cb 100644 --- a/JoshHeaps.Net/Models/MoveResultDto.cs +++ b/JoshHeaps.Net/Models/MoveResultDto.cs @@ -7,4 +7,5 @@ public class MoveResultDto public bool IsCheck { get; set; } public bool IsCheckmate { get; set; } public bool IsStalemate { get; set; } + public bool IsThreefoldRepetition { get; set; } } diff --git a/JoshHeaps.Net/Pages/Chess.cshtml b/JoshHeaps.Net/Pages/Chess.cshtml index df65f87..3c7df31 100644 --- a/JoshHeaps.Net/Pages/Chess.cshtml +++ b/JoshHeaps.Net/Pages/Chess.cshtml @@ -24,6 +24,7 @@
+ Watch other games →
diff --git a/JoshHeaps.Net/Pages/Index.cshtml b/JoshHeaps.Net/Pages/Index.cshtml index b71a3b2..be6aa12 100644 --- a/JoshHeaps.Net/Pages/Index.cshtml +++ b/JoshHeaps.Net/Pages/Index.cshtml @@ -61,6 +61,7 @@

Demos

+ diff --git a/JoshHeaps.Net/Pages/Watch.cshtml b/JoshHeaps.Net/Pages/Watch.cshtml new file mode 100644 index 0000000..80635e6 --- /dev/null +++ b/JoshHeaps.Net/Pages/Watch.cshtml @@ -0,0 +1,34 @@ +@page +@model JoshHeaps.Net.Pages.WatchModel +@{ + Layout = "_Layout"; + ViewData["Title"] = "Watch Chess"; +} + +
+

Live Chess

+

Loading games…

+
+ + + +
+ ← Play a game +
+ +
+ +@section Scripts { + + +} + +@section Styles { + + +} diff --git a/JoshHeaps.Net/Pages/Watch.cshtml.cs b/JoshHeaps.Net/Pages/Watch.cshtml.cs new file mode 100644 index 0000000..33484de --- /dev/null +++ b/JoshHeaps.Net/Pages/Watch.cshtml.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace JoshHeaps.Net.Pages +{ + public class WatchModel : PageModel + { + public void OnGet() + { + } + } +} diff --git a/JoshHeaps.Net/Services/Implementations/ChessService.cs b/JoshHeaps.Net/Services/Implementations/ChessService.cs index adcaf82..613eb59 100644 --- a/JoshHeaps.Net/Services/Implementations/ChessService.cs +++ b/JoshHeaps.Net/Services/Implementations/ChessService.cs @@ -35,6 +35,9 @@ public class ChessService : IChessService gameState.CurrentPlayer = PieceColor.White; UpdateCheckStatus(gameState); + + gameState.PositionHistory.Clear(); + gameState.PositionHistory.Add(PositionKey(gameState)); } private static void SetupBlackPieces(GameState gs) @@ -144,16 +147,33 @@ public class ChessService : IChessService var notation = $"{piece.Id}:{piece.Position}->{targetPos}"; gameState.MoveHistory.Add(notation); + var positionKey = PositionKey(gameState); + gameState.PositionHistory.Add(positionKey); + + if (gameState.PositionHistory.Count(k => k == positionKey) >= 3) + gameState.IsThreefoldRepetition = true; + return new MoveResultDto { Success = true, Message = "Move successful.", IsCheck = gameState.IsCheck, IsCheckmate = gameState.IsCheckmate, - IsStalemate = gameState.IsStalemate + IsStalemate = gameState.IsStalemate, + IsThreefoldRepetition = gameState.IsThreefoldRepetition }; } + /// + /// 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. + /// + private static string PositionKey(GameState gs) + { + var fields = gs.ToFen().Split(' '); + return string.Join(' ', fields.Take(4)); + } + private static void PerformMove(GameState gs, ChessPiece piece, Position targetPos, MoveDto moveDto) { var oldPos = piece.Position; diff --git a/JoshHeaps.Net/wwwroot/css/chess/site.css b/JoshHeaps.Net/wwwroot/css/chess/site.css index c0bee9f..310ec56 100644 --- a/JoshHeaps.Net/wwwroot/css/chess/site.css +++ b/JoshHeaps.Net/wwwroot/css/chess/site.css @@ -28,6 +28,17 @@ margin: 2vh; } +#watchLink { + color: #8cd5ed; + text-decoration: none; + margin: 2vh; + order: 2; +} + +#watchLink:hover { + text-decoration: underline; +} + #chessContainer { display: flex; flex-direction: column; diff --git a/JoshHeaps.Net/wwwroot/css/chess/spectate.css b/JoshHeaps.Net/wwwroot/css/chess/spectate.css new file mode 100644 index 0000000..cb29072 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/css/chess/spectate.css @@ -0,0 +1,134 @@ +html, body { + background-color: #2b2c30; + color: #d6d6d6; + margin: 0; +} + +#watchHeader { + text-align: center; + padding: 2rem 1rem 0; +} + +#watchHeader h1 { + margin: 0; +} + +#watchStatus { + color: #9a9a9a; +} + +#watchControls { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin: 1rem 0; +} + +#cpuDifficulty { + background-color: #1e1e1e; + color: #d6d6d6; + border: 1px solid #444; + border-radius: 6px; + padding: 0.4rem; +} + +#startCpuVsCpu { + background-color: #8cd5ed; + color: #262626; + border: 0; + border-radius: 30px; + padding: 0.6rem 1.2rem; + cursor: pointer; +} + +#startCpuVsCpu:hover { + background-color: #a5e0f2; +} + +#backToPlay { + color: #8cd5ed; + text-decoration: none; +} + +#backToPlay:hover { + text-decoration: underline; +} + +#gamesFeed { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; + padding: 2rem; + box-sizing: border-box; +} + +.gameCard { + background-color: #1e1e1e; + border-radius: 10px; + padding: 1rem; + box-shadow: 0 0 12px rgba(0, 0, 0, 0.4); + position: relative; + cursor: pointer; +} + +.gameResult { + text-align: center; + font-weight: bold; + color: #8cd5ed; + margin-top: 0.75rem; +} + +.backButton { + display: none; +} + +body.fullscreen-open { + overflow: hidden; +} + +.gameCard.fullscreen { + position: fixed; + inset: 0; + z-index: 1000; + margin: 0; + border-radius: 0; + cursor: default; + background-color: #2b2c30; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.gameCard.fullscreen .miniBoard { + width: min(90vh, 90vw); + height: min(90vh, 90vw); +} + +.gameCard.fullscreen .backButton { + display: inline-block; + position: absolute; + top: 1rem; + left: 1rem; + background-color: #8cd5ed; + color: #262626; + border: 0; + border-radius: 30px; + padding: 0.6rem 1.2rem; + cursor: pointer; +} + +.gameCardHeader { + text-align: center; + font-weight: bold; + margin-bottom: 0.75rem; +} + +.miniBoard { + width: 100%; + aspect-ratio: 1 / 1; + display: grid; + grid-template-columns: repeat(8, 1fr); + grid-template-rows: repeat(8, 1fr); +} diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js index 19de069..6262164 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js @@ -14,6 +14,14 @@ const ChessAPI = { return await response.json(); }, + async forfeit(gameId, playerId) { + await fetch("/api/chess/forfeit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ GameId: gameId, PlayerId: playerId }) + }); + }, + async getLegalMoves(pieceId) { const response = await fetch(`/api/chess/${GameState.currentGameId}/legalMoves/${pieceId}`); if (!response.ok) throw new Error("API failed"); @@ -101,6 +109,9 @@ const ChessAPI = { } else if (moveResult.isStalemate) { alert("🤝 Stalemate!"); gameOver = true; + } else if (moveResult.isThreefoldRepetition) { + alert("🤝 Draw by threefold repetition!"); + gameOver = true; } if (gameOver) { diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js index c12e53a..68f35cc 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js @@ -15,6 +15,10 @@ const ChessSignalR = { await this.handleMoveUpdate(gameId, moveDto, moveResultDto); }); + this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => { + await this.handleGameOver(gameId, winner, reason); + }); + try { await this.connection.start(); console.log("✅ SignalR connected"); @@ -34,6 +38,17 @@ const ChessSignalR = { ChessAPI.alertGameStatusChange(moveResultDto); }, + async handleGameOver(gameId, winner, reason) { + if (gameId !== GameState.currentGameId) return; + + const youWon = (winner === "White") === GameState.currentPlayerIsWhite; + + if (reason === "forfeit") + alert(youWon ? "🏳️ Your opponent forfeited — you win!" : "🏳️ You forfeited this game."); + + await this.leaveGame(); + }, + async notifyMoveMade(moveDto, moveResult) { if (this.connection) { await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult); diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js new file mode 100644 index 0000000..c4fea0b --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js @@ -0,0 +1,243 @@ +const Spectate = { + connection: null, + games: new Map(), // gameId -> { isVsComputer, isComputerVsComputer, result } + + pieceTypeNames: ["Pawn", "Rook", "Knight", "Bishop", "Queen", "King"], + + async init() { + this.connection = new signalR.HubConnectionBuilder() + .withUrl("/chessHub") + .configureLogging(signalR.LogLevel.Warning) + .build(); + + this.connection.on("ReceiveMoveUpdate", (gameId, moveDto) => + this.handleMoveUpdate(gameId, moveDto)); + + this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId)); + + try { + await this.connection.start(); + } catch (err) { + console.error("❌ SignalR failed to start:", err); + } + + await this.refreshGames(); + setInterval(() => this.refreshGames(), 5000); + }, + + async startCpuGame() { + const difficulty = document.getElementById("cpuDifficulty").value; + + try { + await fetch(`/api/chess/watch/cpu/${difficulty}`); + await this.refreshGames(); + } catch (err) { + console.error("❌ Could not start CPU vs CPU game.", err); + } + }, + + async refreshGames() { + let games; + + try { + const response = await fetch("/api/chess/active"); + games = await response.json(); + } catch { + return; + } + + const activeIds = new Set(games.map(g => g.gameId)); + + for (const gameId of [...this.games.keys()]) + if (!activeIds.has(gameId)) + await this.removeGame(gameId); + + for (const game of games) + if (this.games.has(game.gameId)) + this.updateHeader(game.gameId, game.currentPlayer, game.moveCount, game.isCheck); + else + await this.addGame(game); + + const status = document.getElementById("watchStatus"); + status.textContent = games.length === 0 + ? "No games are being played right now." + : `${games.length} game${games.length === 1 ? "" : "s"} in progress`; + }, + + async addGame(game) { + this.games.set(game.gameId, { + isVsComputer: game.isVsComputer, + isComputerVsComputer: game.isComputerVsComputer + }); + + const card = document.createElement("div"); + card.className = "gameCard"; + card.id = `card-${game.gameId}`; + card.onclick = () => this.enterFullscreen(game.gameId); + + const back = document.createElement("button"); + back.className = "backButton"; + back.textContent = "← Back"; + back.onclick = (event) => this.exitFullscreen(game.gameId, event); + card.appendChild(back); + + const header = document.createElement("div"); + header.className = "gameCardHeader"; + header.id = `header-${game.gameId}`; + header.textContent = this.headerText(this.games.get(game.gameId), game.currentPlayer, game.moveCount, game.isCheck); + card.appendChild(header); + + const board = document.createElement("div"); + board.className = "miniBoard"; + + for (let i = 0; i < 64; i++) { + const square = document.createElement("div"); + square.id = `sq-${game.gameId}-${i}`; + square.className = `chessSquare ${(i + Math.floor(i / 8)) % 2 === 0 ? "light" : "dark"}`; + board.appendChild(square); + } + + card.appendChild(board); + document.getElementById("gamesFeed").appendChild(card); + + await this.connection.invoke("JoinWebsocketGroup", game.gameId).catch(() => { }); + await this.renderGame(game.gameId); + }, + + async removeGame(gameId) { + this.games.delete(gameId); + document.getElementById(`card-${gameId}`)?.remove(); + await this.connection.invoke("LeaveWebsocketGroup", gameId).catch(() => { }); + }, + + async renderGame(gameId) { + const response = await fetch(`/api/chess/${gameId}`); + + if (!response.ok) return; + + const state = await response.json(); + const result = this.resultTextFromState(state); + const stored = this.games.get(gameId); + + if (stored) stored.result = result; + + this.renderPieces(gameId, state.pieces); + this.updateHeader(gameId, state.currentPlayer, state.moveHistory.length, state.isCheck); + this.setResult(gameId, result); + }, + + async handleMoveUpdate(gameId, moveDto) { + if (!this.games.has(gameId)) return; + + await this.renderGame(gameId); + this.highlightMove(gameId, moveDto); + }, + + renderPieces(gameId, pieces) { + this.clearBoard(gameId); + + pieces.forEach(piece => { + const square = document.getElementById(`sq-${gameId}-${piece.row * 8 + piece.col}`); + + if (!square) return; + + const img = document.createElement("img"); + img.src = this.pieceImageUrl(piece); + img.alt = piece.type; + img.className = "chessPiece"; + img.draggable = false; + square.appendChild(img); + }); + }, + + clearBoard(gameId) { + for (let i = 0; i < 64; i++) { + const square = document.getElementById(`sq-${gameId}-${i}`); + + if (!square) continue; + + square.innerHTML = ""; + square.classList.remove("previous-start", "previous-end"); + } + }, + + highlightMove(gameId, moveDto) { + document.getElementById(`sq-${gameId}-${moveDto.sourceRow * 8 + moveDto.sourceCol}`)?.classList.add("previous-start"); + document.getElementById(`sq-${gameId}-${moveDto.targetRow * 8 + moveDto.targetCol}`)?.classList.add("previous-end"); + }, + + updateHeader(gameId, currentPlayer, moveCount, isCheck) { + const stored = this.games.get(gameId); + const header = document.getElementById(`header-${gameId}`); + + if (stored && header) + header.textContent = this.headerText(stored, currentPlayer, moveCount, isCheck); + }, + + gameLabel(stored) { + if (stored.isComputerVsComputer) return "CPU vs CPU"; + if (stored.isVsComputer) return "Vs CPU"; + return "Player vs Player"; + }, + + headerText(stored, currentPlayer, moveCount, isCheck) { + if (stored.result) + return `${this.gameLabel(stored)} · move ${moveCount} · final`; + + const check = isCheck ? " • check" : ""; + return `${this.gameLabel(stored)} · move ${moveCount} · ${currentPlayer} to move${check}`; + }, + + resultTextFromState(state) { + if (state.isCheckmate) + return `${state.currentPlayer === "White" ? "Black" : "White"} wins by checkmate`; + if (state.isStalemate) + return "Draw — stalemate"; + if (state.isThreefoldRepetition) + return "Draw — threefold repetition"; + return null; + }, + + setResult(gameId, text) { + const card = document.getElementById(`card-${gameId}`); + + if (!card) return; + + let banner = card.querySelector(".gameResult"); + + if (!text) { + banner?.remove(); + card.classList.remove("over"); + return; + } + + if (!banner) { + banner = document.createElement("div"); + banner.className = "gameResult"; + card.appendChild(banner); + } + + banner.textContent = text; + card.classList.add("over"); + }, + + enterFullscreen(gameId) { + document.getElementById(`card-${gameId}`)?.classList.add("fullscreen"); + document.body.classList.add("fullscreen-open"); + }, + + exitFullscreen(gameId, event) { + event?.stopPropagation(); + document.getElementById(`card-${gameId}`)?.classList.remove("fullscreen"); + document.body.classList.remove("fullscreen-open"); + }, + + pieceImageUrl(piece) { + const color = piece.color === 0 ? "White" : "Black"; + return `/images/Chess Images/${color}${this.pieceTypeNames[piece.type]}.svg`; + } +}; + +window.addEventListener("load", () => Spectate.init()); + +console.log("Spectate.js loaded"); diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js index abae265..9bfda72 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js @@ -1,5 +1,19 @@ +async function forfeitCurrentGame() { + const gameId = ChessUtils.getCookie("chessGameId"); + const playerId = ChessUtils.getCookie("chessPlayerId"); + + if (!gameId || !playerId) return; + + try { + await ChessAPI.forfeit(gameId, playerId); + } catch (err) { + console.warn("Could not forfeit previous game.", err); + } +} + async function startNewGame() { await ChessSignalR.stopConnection(); + await forfeitCurrentGame(); try { const gameData = await ChessAPI.joinGame(); @@ -25,6 +39,7 @@ async function startNewGame() { async function startCPUGame() { await ChessSignalR.stopConnection(); + await forfeitCurrentGame(); try { const difficulty = await ChessModals.promptDifficulty();