Add spectator watch page, forfeit-on-new-game, CPU self-play, and threefold repetition

- /watch live feed of active games with click-to-fullscreen and per-game back button
- Starting a new game forfeits the in-progress game; opponent is flagged the winner
- Watch CPU vs CPU self-play games; results shown for ~30s before cleanup
- Detect threefold repetition in the C# rules layer and end the game as a draw

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Josh-Heaps
2026-06-08 16:21:49 -06:00
co-authored by Claude Opus 4.8
parent fc2a2e122b
commit 2db95cec34
15 changed files with 642 additions and 5 deletions
@@ -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) {
@@ -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);
@@ -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");
@@ -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();