From e8a3bfe4327bdfa774e9cfe3e563a623b99625fd Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Wed, 10 Jun 2026 16:57:51 -0600 Subject: [PATCH] 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) --- JoshHeaps.Net/Controllers/ChessController.cs | 40 ++++-------- JoshHeaps.Net/Hubs/ChessHub.cs | 8 +-- JoshHeaps.Net/Models/GameStateDto.cs | 61 +++++++++++++++++++ .../ComputerMoveOrchestrator.cs | 2 +- 4 files changed, 74 insertions(+), 37 deletions(-) create mode 100644 JoshHeaps.Net/Models/GameStateDto.cs diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index 6e63375..df9190f 100644 --- a/JoshHeaps.Net/Controllers/ChessController.cs +++ b/JoshHeaps.Net/Controllers/ChessController.cs @@ -176,33 +176,7 @@ public class ChessController( if (!_games.TryGetValue(gameId, out var gameState)) return NotFound("Game not found"); - var response = new - { - 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); + return Ok(gameState.ToDto()); } /// @@ -210,7 +184,7 @@ public class ChessController( /// The test passes a JSON body with a MoveDto. /// [HttpPost("move")] - public ActionResult MakeMove([FromBody] MoveDto moveDto) + public async Task MakeMove([FromBody] MoveDto moveDto) { if (!_games.TryGetValue(moveDto.GameId, out var gameState)) return NotFound("Game not found"); @@ -245,10 +219,18 @@ public class ChessController( else 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) queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!)); - return Ok(result); + return Ok(new { result, state }); } /// diff --git a/JoshHeaps.Net/Hubs/ChessHub.cs b/JoshHeaps.Net/Hubs/ChessHub.cs index 0cb59a1..1305721 100644 --- a/JoshHeaps.Net/Hubs/ChessHub.cs +++ b/JoshHeaps.Net/Hubs/ChessHub.cs @@ -1,5 +1,4 @@ -using JoshHeaps.Net.Models; -using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.SignalR; namespace JoshHeaps.Net.Hubs; @@ -11,11 +10,6 @@ public class ChessHub : Hub 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) { Console.WriteLine($"❌ Leaving group {gameId}"); diff --git a/JoshHeaps.Net/Models/GameStateDto.cs b/JoshHeaps.Net/Models/GameStateDto.cs new file mode 100644 index 0000000..8279acd --- /dev/null +++ b/JoshHeaps.Net/Models/GameStateDto.cs @@ -0,0 +1,61 @@ +using System.Linq; + +namespace JoshHeaps.Net.Models; + +/// +/// A single piece as sent to the client. Captured pieces keep their original +/// type and color; their position is meaningless and omitted. +/// +public record ChessPieceDto(string Id, PieceType Type, PieceColor Color, int Row, int Col, bool HasMoved); + +/// +/// 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. +/// +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 Pieces, + IReadOnlyList CapturedPieces, + IReadOnlyList MoveHistory, + // Moves in standard algebraic notation, for the move-list panel. + IReadOnlyList 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); +} diff --git a/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs b/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs index 21c15b1..244b6f6 100644 --- a/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs +++ b/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs @@ -26,7 +26,7 @@ public sealed class ComputerMoveOrchestrator( var result = chessService.MakeMove(state, move); 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); }