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);
}