Push authoritative game state on every move
The move endpoint now returns the full resulting board state, and the controller and CPU orchestrator broadcast it (plus captured pieces and a ply version) over SignalR, so clients render from one payload instead of re-fetching. Removes the client-driven hub relay. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8b78fd589c
commit
e8a3bfe432
@@ -176,33 +176,7 @@ public class ChessController(
|
|||||||
if (!_games.TryGetValue(gameId, out var gameState))
|
if (!_games.TryGetValue(gameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
|
|
||||||
var response = new
|
return Ok(gameState.ToDto());
|
||||||
{
|
|
||||||
gameState.GameId,
|
|
||||||
CurrentPlayer = gameState.CurrentPlayer.ToString(),
|
|
||||||
gameState.IsCheck,
|
|
||||||
gameState.IsCheckmate,
|
|
||||||
gameState.IsStalemate,
|
|
||||||
gameState.IsThreefoldRepetition,
|
|
||||||
EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null,
|
|
||||||
gameState.WhiteCanCastleKingside,
|
|
||||||
gameState.WhiteCanCastleQueenside,
|
|
||||||
gameState.BlackCanCastleKingside,
|
|
||||||
gameState.BlackCanCastleQueenside,
|
|
||||||
Pieces = gameState.Pieces
|
|
||||||
.Where(p => p.Position.Row >= 0)
|
|
||||||
.Select(p => new {
|
|
||||||
p.Id,
|
|
||||||
p.Type,
|
|
||||||
p.Color,
|
|
||||||
p.Position.Row,
|
|
||||||
p.Position.Col,
|
|
||||||
p.HasMoved
|
|
||||||
}),
|
|
||||||
gameState.MoveHistory
|
|
||||||
};
|
|
||||||
|
|
||||||
return Ok(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -210,7 +184,7 @@ public class ChessController(
|
|||||||
/// The test passes a JSON body with a MoveDto.
|
/// The test passes a JSON body with a MoveDto.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("move")]
|
[HttpPost("move")]
|
||||||
public ActionResult MakeMove([FromBody] MoveDto moveDto)
|
public async Task<ActionResult> MakeMove([FromBody] MoveDto moveDto)
|
||||||
{
|
{
|
||||||
if (!_games.TryGetValue(moveDto.GameId, out var gameState))
|
if (!_games.TryGetValue(moveDto.GameId, out var gameState))
|
||||||
return NotFound("Game not found");
|
return NotFound("Game not found");
|
||||||
@@ -245,10 +219,18 @@ public class ChessController(
|
|||||||
else
|
else
|
||||||
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
||||||
|
|
||||||
|
var state = gameState.ToDto();
|
||||||
|
|
||||||
|
// Broadcast the move (with the full resulting state) to everyone watching this
|
||||||
|
// game. The mover also receives this echo but drops it via the version guard,
|
||||||
|
// since it already rendered the same state from this response.
|
||||||
|
await chessHub.Clients.Group(gameState.GameId.ToString())
|
||||||
|
.SendAsync("ReceiveMoveUpdate", gameState.GameId.ToString(), moveDto, result, state);
|
||||||
|
|
||||||
if (!isGameOver && gameState.IsVsComputer && gameState.Computer is not null)
|
if (!isGameOver && gameState.IsVsComputer && gameState.Computer is not null)
|
||||||
queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!));
|
queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!));
|
||||||
|
|
||||||
return Ok(result);
|
return Ok(new { result, state });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using JoshHeaps.Net.Models;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
|
||||||
|
|
||||||
namespace JoshHeaps.Net.Hubs;
|
namespace JoshHeaps.Net.Hubs;
|
||||||
|
|
||||||
@@ -11,11 +10,6 @@ public class ChessHub : Hub
|
|||||||
await Groups.AddToGroupAsync(Context.ConnectionId, gameId);
|
await Groups.AddToGroupAsync(Context.ConnectionId, gameId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MoveMade(string gameId, MoveDto moveDto, MoveResultDto moveResult)
|
|
||||||
{
|
|
||||||
await Clients.OthersInGroup(gameId).SendAsync("ReceiveMoveUpdate", gameId, moveDto, moveResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task LeaveWebsocketGroup(string gameId)
|
public async Task LeaveWebsocketGroup(string gameId)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"❌ Leaving group {gameId}");
|
Console.WriteLine($"❌ Leaving group {gameId}");
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single piece as sent to the client. Captured pieces keep their original
|
||||||
|
/// type and color; their position is meaningless and omitted.
|
||||||
|
/// </summary>
|
||||||
|
public record ChessPieceDto(string Id, PieceType Type, PieceColor Color, int Row, int Col, bool HasMoved);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The full board state pushed to clients. The same shape is returned by the
|
||||||
|
/// state endpoint, the move endpoint, and every SignalR move broadcast, so the
|
||||||
|
/// client always renders from one authoritative payload instead of re-fetching.
|
||||||
|
/// </summary>
|
||||||
|
public record GameStateDto(
|
||||||
|
Guid GameId,
|
||||||
|
string CurrentPlayer,
|
||||||
|
bool IsCheck,
|
||||||
|
bool IsCheckmate,
|
||||||
|
bool IsStalemate,
|
||||||
|
bool IsThreefoldRepetition,
|
||||||
|
string? EnPassantTarget,
|
||||||
|
bool WhiteCanCastleKingside,
|
||||||
|
bool WhiteCanCastleQueenside,
|
||||||
|
bool BlackCanCastleKingside,
|
||||||
|
bool BlackCanCastleQueenside,
|
||||||
|
IReadOnlyList<ChessPieceDto> Pieces,
|
||||||
|
IReadOnlyList<ChessPieceDto> CapturedPieces,
|
||||||
|
IReadOnlyList<string> MoveHistory,
|
||||||
|
// Moves in standard algebraic notation, for the move-list panel.
|
||||||
|
IReadOnlyList<string> SanHistory,
|
||||||
|
// Monotonic ply counter the client uses to drop stale or echoed updates.
|
||||||
|
int Version);
|
||||||
|
|
||||||
|
public static class GameStateMapper
|
||||||
|
{
|
||||||
|
public static GameStateDto ToDto(this GameState gameState) => new(
|
||||||
|
gameState.GameId,
|
||||||
|
gameState.CurrentPlayer.ToString(),
|
||||||
|
gameState.IsCheck,
|
||||||
|
gameState.IsCheckmate,
|
||||||
|
gameState.IsStalemate,
|
||||||
|
gameState.IsThreefoldRepetition,
|
||||||
|
gameState.EnPassantTarget?.ToString(),
|
||||||
|
gameState.WhiteCanCastleKingside,
|
||||||
|
gameState.WhiteCanCastleQueenside,
|
||||||
|
gameState.BlackCanCastleKingside,
|
||||||
|
gameState.BlackCanCastleQueenside,
|
||||||
|
gameState.Pieces
|
||||||
|
.Where(p => p.Position.Row >= 0)
|
||||||
|
.Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved))
|
||||||
|
.ToList(),
|
||||||
|
gameState.Pieces
|
||||||
|
.Where(p => p.Position.Row < 0)
|
||||||
|
.Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved))
|
||||||
|
.ToList(),
|
||||||
|
gameState.MoveHistory,
|
||||||
|
gameState.SanHistory,
|
||||||
|
gameState.MoveHistory.Count);
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ public sealed class ComputerMoveOrchestrator(
|
|||||||
var result = chessService.MakeMove(state, move);
|
var result = chessService.MakeMove(state, move);
|
||||||
|
|
||||||
await chessHub.Clients.Group(state.GameId.ToString())
|
await chessHub.Clients.Group(state.GameId.ToString())
|
||||||
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result);
|
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result, state.ToDto());
|
||||||
|
|
||||||
return (move, result);
|
return (move, result);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user