Gitea/workflows #1
@@ -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> chessHub) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 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);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new chess game and store it in-memory.
|
||||
@@ -70,6 +75,31 @@ public class ChessController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Joins the "pool" of chess players.
|
||||
/// Test code expects to receive a GUID for the player
|
||||
@@ -112,6 +142,30 @@ public class ChessController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List in-progress games for spectators: both sides present and the game not yet decided.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the state of an existing game by ID.
|
||||
/// </summary>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[HttpPost("forfeit")]
|
||||
public async Task<ActionResult> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the legal moves for a specific piece in a specific game.
|
||||
/// </summary>
|
||||
@@ -226,6 +313,42 @@ public class ChessController(
|
||||
return Ok(allMoves);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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))
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace JoshHeaps.Net.Models;
|
||||
|
||||
public class ForfeitDto
|
||||
{
|
||||
public Guid GameId { get; set; }
|
||||
public Guid PlayerId { get; set; }
|
||||
}
|
||||
@@ -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<string> MoveHistory { get; set; }
|
||||
|
||||
// Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection.
|
||||
public List<string> PositionHistory { get; set; }
|
||||
|
||||
// A list of all pieces to quickly reference them (optional but convenient).
|
||||
// Alternatively, you can iterate the Board array.
|
||||
public List<ChessPiece> 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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<div id="buttonContainer" class="sideContent">
|
||||
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
|
||||
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button>
|
||||
<a id="watchLink" href="/watch">Watch other games →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
<h2 class="section-title">Demos</h2>
|
||||
<div id="buttonWrapper">
|
||||
<button class="demoButton" onclick="window.location.href='/chess'">Play Chess</button>
|
||||
<button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button>
|
||||
<button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
|
||||
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
|
||||
<button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
@page
|
||||
@model JoshHeaps.Net.Pages.WatchModel
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
ViewData["Title"] = "Watch Chess";
|
||||
}
|
||||
|
||||
<div id="watchHeader">
|
||||
<h1>Live Chess</h1>
|
||||
<p id="watchStatus">Loading games…</p>
|
||||
<div id="watchControls">
|
||||
<label for="cpuDifficulty">Difficulty</label>
|
||||
<select id="cpuDifficulty">
|
||||
@for (int i = 1; i <= 20; i++)
|
||||
{
|
||||
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
|
||||
}
|
||||
</select>
|
||||
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
|
||||
</div>
|
||||
<a id="backToPlay" href="/chess">← Play a game</a>
|
||||
</div>
|
||||
|
||||
<div id="gamesFeed"></div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/signalr/signalr.min.js"></script>
|
||||
<script src="~/js/ChessScripts/Spectate.js"></script>
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
|
||||
<link rel="stylesheet" href="~/css/chess/spectate.css?v=@ViewData["cssVersion"]" />
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JoshHeaps.Net.Pages
|
||||
{
|
||||
public class WatchModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user