diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js index e935454..19f872d 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js @@ -46,13 +46,15 @@ const ChessAPI = { throw new Error(message || "Invalid move or not your turn."); } - const result = await response.json(); + // The move endpoint returns both the move result and the full resulting + // board state, so the caller can render without a follow-up fetch. + const data = await response.json(); - if (!result.success) { - throw new Error(result.message || "Invalid move or not your turn."); + if (!data.result.success) { + throw new Error(data.result.message || "Invalid move or not your turn."); } - return result; + return data; }, async handleMove(targetRow, targetCol) { @@ -85,16 +87,14 @@ const ChessAPI = { GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]); try { - const moveResult = await this.makeMove(moveDto); + const { result, state } = await this.makeMove(moveDto); - const updatedGame = await this.getGameState(GameState.currentGameId); - ChessBoard.renderPieces(updatedGame.pieces); + ChessBoard.renderState(state); GameState.clearSelection(); ChessInteractions.clearHighlights(); - await ChessSignalR.notifyMoveMade(moveDto, moveResult); - this.alertGameStatusChange(moveResult); + this.alertGameStatusChange(result); } catch (error) { alert("❌ " + error.message); diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessBoard.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessBoard.js index 0c5a59e..aa019ed 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessBoard.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessBoard.js @@ -1,4 +1,91 @@ +// Material value per piece type, indexed by PieceType (0=Pawn .. 5=King). +const PIECE_VALUES = [1, 5, 3, 3, 9, 0]; + const ChessBoard = { + // Render from a full game-state payload (the shape returned by the move/state + // endpoints and pushed over SignalR). Ignores state older than what's already + // shown, so a slow initial fetch can't clobber a move that arrived first. + renderState(state) { + if (!state || !GameState.shouldApply(state.version)) return; + this.renderPieces(state.pieces); + this.renderCapturedTrays(state.pieces, state.capturedPieces); + this.renderMoveList(state.sanHistory); + this.renderStatus(state); + GameState.setVersion(state.version); + }, + + // Two-column numbered move list (white move, black move), latest highlighted. + renderMoveList(sanHistory) { + const list = document.getElementById("moveList"); + if (!list) return; + + const san = sanHistory ?? []; + + if (san.length === 0) { + list.innerHTML = '
Moves will appear here once a game begins.
'; + return; + } + + list.innerHTML = ""; + + for (let i = 0; i < san.length; i += 2) { + const row = document.createElement("div"); + row.className = "moveRow"; + + const num = document.createElement("span"); + num.className = "moveNum"; + num.textContent = `${i / 2 + 1}.`; + row.appendChild(num); + + row.appendChild(this.moveCell(san[i], i === san.length - 1)); + + if (i + 1 < san.length) + row.appendChild(this.moveCell(san[i + 1], i + 1 === san.length - 1)); + + list.appendChild(row); + } + + list.scrollTop = list.scrollHeight; + }, + + moveCell(san, isLatest) { + const cell = document.createElement("span"); + cell.className = isLatest ? "moveSan latest" : "moveSan"; + cell.textContent = san; + return cell; + }, + + renderStatus(state) { + let text; + let alert = false; + + if (state.isCheckmate) { + const winner = state.currentPlayer === "White" ? "Black" : "White"; + text = `Checkmate — ${winner} wins`; + alert = true; + } else if (state.isStalemate) { + text = "Draw — stalemate"; + alert = true; + } else if (state.isThreefoldRepetition) { + text = "Draw — threefold repetition"; + alert = true; + } else { + text = `${state.currentPlayer} to move`; + if (state.isCheck) { + text += " — check"; + alert = true; + } + } + + // Mirror to both the desktop panel status and the mobile bar status. + ["statusLine", "mobileStatus"].forEach(id => { + const el = document.getElementById(id); + if (!el) return; + el.textContent = text; + el.classList.toggle("alert", alert); + }); + }, + renderPieces(pieces) { this.clearAllSquares(); this.renderCoordinateLabels(); @@ -7,6 +94,48 @@ const ChessBoard = { this.highlightPreviousMove(); }, + // Show each side's captured pieces and the leading side's material advantage, + // arranged so the current player's tray sits below the board. + renderCapturedTrays(activePieces, capturedPieces) { + const captured = capturedPieces ?? []; + + // A captured piece's color is the side that lost it, so White's haul is the + // captured Black pieces, and vice versa. + const whiteCaptured = captured.filter(p => p.color === 1); + const blackCaptured = captured.filter(p => p.color === 0); + + // Net material from pieces still on the board, so promotions count correctly. + const advantage = (activePieces ?? []).reduce( + (sum, p) => sum + (p.color === 0 ? PIECE_VALUES[p.type] : -PIECE_VALUES[p.type]), 0); + + const white = { captured: whiteCaptured, advantage: Math.max(advantage, 0) }; + const black = { captured: blackCaptured, advantage: Math.max(-advantage, 0) }; + + const isWhite = GameState.currentPlayerIsWhite !== false; + this.fillCapturedTray("bottom", isWhite ? white : black); + this.fillCapturedTray("top", isWhite ? black : white); + }, + + fillCapturedTray(position, side) { + const tray = document.getElementById(`captured-${position}`); + const badge = document.getElementById(`advantage-${position}`); + if (!tray || !badge) return; + + tray.innerHTML = ""; + [...side.captured] + .sort((a, b) => PIECE_VALUES[a.type] - PIECE_VALUES[b.type]) + .forEach(piece => { + const img = document.createElement("img"); + img.src = ChessUtils.getPieceImageUrl(piece); + img.alt = piece.type; + img.className = "capturedPiece"; + img.draggable = false; + tray.appendChild(img); + }); + + badge.textContent = side.advantage > 0 ? `+${side.advantage}` : ""; + }, + clearAllSquares() { for (let i = 0; i < 64; i++) { const square = document.getElementById(`square-${i}`); diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js index de0ef5b..fa8200d 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js @@ -11,8 +11,8 @@ const ChessSignalR = { console.error("❌ SignalR connection closed:", err?.message); }); - this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto) => { - await this.handleMoveUpdate(gameId, moveDto, moveResultDto); + this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto, state) => { + await this.handleMoveUpdate(gameId, moveDto, moveResultDto, state); }); this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => { @@ -28,12 +28,14 @@ const ChessSignalR = { } }, - async handleMoveUpdate(gameId, moveDto, moveResultDto) { + async handleMoveUpdate(gameId, moveDto, moveResultDto, state) { if (gameId !== GameState.currentGameId) return; - const gameState = await ChessAPI.getGameState(gameId); + // Drop the echo of our own move and any out-of-order delivery. + if (!GameState.shouldApply(state?.version)) return; + GameState.setPreviousMove([moveDto.sourceRow, moveDto.sourceCol], [moveDto.targetRow, moveDto.targetCol]); - ChessBoard.renderPieces(gameState.pieces); + ChessBoard.renderState(state); ChessAPI.alertGameStatusChange(moveResultDto); }, @@ -50,12 +52,6 @@ const ChessSignalR = { await this.leaveGame(); }, - async notifyMoveMade(moveDto, moveResult) { - if (this.connection) { - await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult); - } - }, - async leaveGame() { if (this.connection) { await this.connection.invoke("LeaveWebsocketGroup", GameState.currentGameId); diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/GameState.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/GameState.js index 84416f9..1a0de6c 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/GameState.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/GameState.js @@ -6,6 +6,10 @@ const GameState = { legalMoves: [], previousMoveStart: null, previousMoveEnd: null, + // Highest ply (move count) already rendered. Lets us ignore stale or + // already-applied updates that arrive out of order, including the echo + // of our own move. + lastVersion: -1, reset() { this.currentGameId = null; @@ -15,12 +19,22 @@ const GameState = { this.legalMoves = []; this.previousMoveStart = null; this.previousMoveEnd = null; + this.lastVersion = -1; + }, + + shouldApply(version) { + return typeof version !== "number" || version > this.lastVersion; + }, + + setVersion(version) { + if (typeof version === "number") this.lastVersion = version; }, setGameInfo(gameId, playerId, isWhite) { this.currentGameId = gameId; this.currentPlayerId = playerId; this.currentPlayerIsWhite = isWhite; + this.lastVersion = -1; }, setSelectedPiece(piece) { diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js index 96debc7..08f3450 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js @@ -11,8 +11,8 @@ const Spectate = { .configureLogging(signalR.LogLevel.Warning) .build(); - this.connection.on("ReceiveMoveUpdate", (gameId, moveDto) => - this.handleMoveUpdate(gameId, moveDto)); + this.connection.on("ReceiveMoveUpdate", (gameId, moveDto, _moveResult, state) => + this.handleMoveUpdate(gameId, moveDto, state)); this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId)); @@ -117,7 +117,10 @@ const Spectate = { if (!response.ok) return; - const state = await response.json(); + this.renderFromState(gameId, await response.json()); + }, + + renderFromState(gameId, state) { const result = this.resultTextFromState(state); const stored = this.games.get(gameId); @@ -128,10 +131,13 @@ const Spectate = { this.setResult(gameId, result); }, - async handleMoveUpdate(gameId, moveDto) { + async handleMoveUpdate(gameId, moveDto, state) { if (!this.games.has(gameId)) return; - await this.renderGame(gameId); + // Render from the pushed state; fall back to a fetch only if it's missing. + if (state) this.renderFromState(gameId, state); + else await this.renderGame(gameId); + this.highlightMove(gameId, moveDto); }, diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js index 6cdac8b..826816f 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js @@ -30,6 +30,18 @@ function resetCopyPgn() { window.copyPgn = copyPgn; window.showCopyPgn = showCopyPgn; +// Mobile slide-up menu (move list + secondary actions). +function toggleMenu() { + document.body.classList.toggle("menu-open"); +} + +function closeMenu() { + document.body.classList.remove("menu-open"); +} + +window.toggleMenu = toggleMenu; +window.closeMenu = closeMenu; + async function forfeitCurrentGame() { const gameId = ChessUtils.getCookie("chessGameId"); const playerId = ChessUtils.getCookie("chessPlayerId"); @@ -44,6 +56,7 @@ async function forfeitCurrentGame() { } async function startNewGame() { + closeMenu(); await ChessSignalR.stopConnection(); await forfeitCurrentGame(); resetCopyPgn(); @@ -63,7 +76,7 @@ async function startNewGame() { await ChessSignalR.setupConnection(); const gameState = await ChessAPI.getGameState(gameData.gameId); - ChessBoard.renderPieces(gameState.pieces); + ChessBoard.renderState(gameState); } catch (error) { console.error("Failed to start new game:", error); @@ -71,6 +84,7 @@ async function startNewGame() { } async function startCPUGame() { + closeMenu(); await ChessSignalR.stopConnection(); await forfeitCurrentGame(); resetCopyPgn(); @@ -91,7 +105,7 @@ async function startCPUGame() { await ChessSignalR.setupConnection(); const gameState = await ChessAPI.getGameState(gameData.gameId); - ChessBoard.renderPieces(gameState.pieces); + ChessBoard.renderState(gameState); } catch (error) { console.error("Failed to start CPU game:", error); @@ -111,7 +125,7 @@ async function resumeSavedGame() { GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true"); await ChessSignalR.setupConnection(); - ChessBoard.renderPieces(gameState.pieces); + ChessBoard.renderState(gameState); } catch (err) { console.warn("Saved game not found or expired.", err);