Continuing with my web-based checkers game, this question is about the actual playing system. I'll ask for a review on my UI system in the next post.
First, my BoardController.cs. Note that I have references to a ComponentGenerator
class here and in other files. I consider this part of the UI, and will post it later. In fact, that is part of the reason I want a dedicated UI post--so it is up front and center.
public class BoardController : Controller
{
private readonly IMediator _mediator;
private readonly Database.Context _context;
private readonly IHubContext<GameHub> _signalRHub;
private readonly ComputerPlayer _computerPlayer;
public BoardController(Database.Context context,
IHubContext<GameHub> signalRHub,
ComputerPlayer computerPlayer,
IMediator mediator)
{
_context = context;
_signalRHub = signalRHub;
_computerPlayer = computerPlayer;
_mediator = mediator;
}
private Theme GetThemeOrDefault()
{
if (Request.Cookies.Keys.All(a => a != "theme"))
{
return Theme.Steel;
}
return Enum.Parse(typeof(Theme), Request.Cookies["theme"]) as Theme? ?? Theme.Steel;
}
private Guid? GetPlayerID()
{
if (Request.Cookies.TryGetValue("playerID", out var id))
{
return Guid.Parse(id);
}
return null;
}
private string GetClientConnection(Guid id)
{
return _context.Players.Find(id).ConnectionID;
}
public ActionResult MovePiece(Guid id, Coord start, Coord end)
{
var playerID = GetPlayerID();
if (!playerID.HasValue)
{
Response.StatusCode = 403;
return Content("");
}
var game = _context.Games
.Include("Turns")
.Include("Turns.Moves")
.FirstOrDefault(f => f.ID == id);
if (game == null ||
game.GameStatus != (int)Status.InProgress ||
(game.BlackPlayerID != playerID && game.CurrentPlayer == (int)Player.Black) ||
(game.WhitePlayerID != playerID && game.CurrentPlayer == (int)Player.White))
{
Response.StatusCode = 403;
return Content("");
}
var controller = game.ToGameController();
if (!controller.IsValidMove(start, end))
{
Response.StatusCode = 403;
return Content("");
}
var move = controller.Move(start, end);
move.ID = game.ID;
var turn = move.MoveHistory.Last().ToPdnTurn();
if (game.Turns.Any(t => t.MoveNumber == turn.MoveNumber))
{
var recordedTurn = game.Turns.Single(s => s.MoveNumber == turn.MoveNumber);
Database.PdnMove newMove;
switch (controller.CurrentPlayer)
{
case Player.White:
newMove = move.MoveHistory.Last().WhiteMove.ToPdnMove();
break;
case Player.Black:
newMove = move.MoveHistory.Last().BlackMove.ToPdnMove();
break;
default:
throw new ArgumentException();
}
var existingMove = recordedTurn.Moves.FirstOrDefault(a => a.Player == (int)controller.CurrentPlayer);
if (existingMove != null)
{
recordedTurn.Moves.Remove(existingMove);
}
recordedTurn.Moves.Add(newMove);
game.Fen = newMove.ResultingFen;
}
else
{
game.Turns.Add(move.MoveHistory.Last().ToPdnTurn());
game.Fen = turn.Moves.Single().ResultingFen;
}
game.CurrentPosition = move.GetCurrentPosition();
game.CurrentPlayer = (int)move.CurrentPlayer;
game.GameStatus = (int)move.GetGameStatus();
game.RowVersion = DateTime.Now;
_context.SaveChanges();
var viewModel = game.ToGameViewModel();
_mediator.Publish(new OnMoveNotification(viewModel)).Wait();
return Content("");
}
public ActionResult Undo(Guid id)
{
var playerID = GetPlayerID();
if (!playerID.HasValue)
{
Response.StatusCode = 403;
return Content("");
}
var game = _context.Games
.Include("Turns")
.Include("Turns.Moves")
.FirstOrDefault(f => f.ID == id);
if (game == null ||
game.GameStatus != (int)Status.InProgress ||
game.BlackPlayerID != playerID && game.WhitePlayerID != playerID ||
game.BlackPlayerID == ComputerPlayer.ComputerPlayerID ||
game.WhitePlayerID == ComputerPlayer.ComputerPlayerID)
{
Response.StatusCode = 403;
return Content("");
}
var lastTurn = game.Turns.OrderBy(a => a.MoveNumber).Last();
if (lastTurn.Moves.Count == 2)
{
// todo: figure out which is the last move based on variant
lastTurn.Moves.Remove(lastTurn.Moves.Single(s => (Player)s.Player == Player.White));
game.Fen = game.Turns.Last().Moves.Single(s => (Player)s.Player == Player.Black).ResultingFen;
}
else
{
if (game.Turns.Count == 1)
{
Response.StatusCode = 403;
return null;
}
game.Turns.Remove(lastTurn);
game.Fen = game.Turns.Last().Moves.Single(s => (Player)s.Player == Player.White).ResultingFen;
}
game.CurrentPosition = -1;
switch ((Player)game.CurrentPlayer)
{
case Player.White:
game.CurrentPlayer = (int)Player.Black;
break;
case Player.Black:
game.CurrentPlayer = (int)Player.White;
break;
default:
break;
}
game.RowVersion = DateTime.Now;
_context.SaveChanges();
_mediator.Publish(new OnMoveNotification(game.ToGameViewModel())).Wait();
return Content("");
}
public ActionResult Resign(Guid id)
{
var playerID = GetPlayerID();
if (!playerID.HasValue)
{
Response.StatusCode = 403;
return Content("");
}
var game = _context.Games.FirstOrDefault(f => f.ID == id);
if (game == null ||
game.GameStatus != (int) Status.InProgress ||
game.BlackPlayerID != playerID && game.WhitePlayerID != playerID)
{
Response.StatusCode = 403;
return Content("");
}
if (game.BlackPlayerID == Guid.Empty || game.WhitePlayerID == Guid.Empty)
{
game.GameStatus = (int)Status.Aborted;
}
else
{
game.GameStatus = playerID == game.BlackPlayerID ? (int)Status.WhiteWin : (int)Status.BlackWin;
}
game.RowVersion = DateTime.Now;
_context.SaveChanges();
_mediator.Publish(new OnGameCompletedNotification(game.ToGameViewModel()));
return Content("");
}
public ActionResult DisplayGame(Guid moveID, Player orientation)
{
var game = _context.Games
.Include("Turns")
.Include("Turns.Moves")
.FirstOrDefault(f => f.Turns.Any(a => a.Moves.Any(m => m.ID == moveID)));
if (game == null)
{
Response.StatusCode = 403;
return Content("");
}
var move = game.Turns.SelectMany(t => t.Moves).First(f => f.ID == moveID);
var viewData = new Dictionary<string, object>
{
["playerID"] = GetPlayerID(),
["orientation"] = orientation
};
var controller = GameController.FromPosition(Variant.AmericanCheckers, move.ResultingFen);
var viewModel = game.ToGameViewModel();
viewModel.Board.GameBoard = controller.Board.GameBoard;
viewModel.DisplayingLastMove = false;
var board = ComponentGenerator.GetBoard(viewModel, viewData).Replace("[theme]", GetThemeOrDefault().ToString());
return Content(board);
}
public ActionResult Join(Guid id)
{
var playerID = GetPlayerID();
if (!playerID.HasValue)
{
Response.StatusCode = 403;
return Content("");
}
var game = _context.Games.FirstOrDefault(f => f.ID == id);
if (game.BlackPlayerID != Guid.Empty && game.WhitePlayerID != Guid.Empty ||
game.BlackPlayerID == playerID ||
game.WhitePlayerID == playerID)
{
Response.StatusCode = 403;
return Content("");
}
if (game.BlackPlayerID == Guid.Empty)
{
game.BlackPlayerID = playerID.Value;
}
else if (game.WhitePlayerID == Guid.Empty)
{
game.WhitePlayerID = playerID.Value;
}
game.RowVersion = DateTime.Now;
try
{
_context.SaveChanges();
var gameViewModel = game.ToGameViewModel();
_mediator.Publish(new OnGameJoinedNotification(gameViewModel, playerID.Value)).Wait();
var viewData = new Dictionary<string, object>
{
["playerID"] = playerID,
["orientation"] = game.BlackPlayerID == playerID ? Player.Black : Player.White,
};
var board = ComponentGenerator.GetBoard(gameViewModel, viewData).Replace("[theme]", GetThemeOrDefault().ToString());
return Content(board);
}
catch (DbUpdateConcurrencyException)
{
_signalRHub.Clients.All.InvokeAsync("AddClass", "join", "hide");
Response.StatusCode = 403;
return Content(Resources.Resources.GameJoined);
}
}
public ActionResult Orientate(Guid id, Guid? moveID, Player orientation)
{
var game = _context.Games
.Include("Turns")
.Include("Turns.Moves")
.FirstOrDefault(f => f.ID == id);
if (game == null)
{
Response.StatusCode = 403;
return Content("");
}
var move = game.Turns.SelectMany(t => t.Moves).FirstOrDefault(f => f.ID == moveID) ??
game.Turns.OrderBy(o => o.MoveNumber).LastOrDefault()?.Moves.OrderBy(a => a.CreatedOn).LastOrDefault();
Dictionary<string, object>
viewData = new Dictionary<string, object>
{
["playerID"] = GetPlayerID(),
["orientation"] = orientation
};
var viewModel = game.ToGameViewModel();
if (moveID != null && moveID.Value != game.Turns.Last().Moves.OrderBy(o => o.CreatedOn).Last().ID)
{
var fen = move.ResultingFen;
var controller = GameController.FromPosition((Variant)game.Variant, fen);
viewModel.Board.GameBoard = controller.Board.GameBoard;
viewModel.DisplayingLastMove = false;
}
var board = ComponentGenerator.GetBoard(viewModel, viewData).Replace("[theme]", GetThemeOrDefault().ToString());
return Content(board);
}
}
I set up the concept of an Action
to make it easy to perform multiple discrete responses when something happens. Here they are, in sequence of being performed:
The GameCreated actions (actually used in the HomeController
, but since I'm getting the rest of them reviewed anyway...):
public class OnGameCreatedNotification : INotification
{
public GameViewModel ViewModel { get; }
public OnGameCreatedNotification(GameViewModel viewModel, Guid currentPlayerID)
{
ViewModel = viewModel;
}
}
public class DoComputerMoveAction : INotificationHandler<OnGameCreatedNotification>
{
private readonly ComputerPlayer _computerPlayer;
private readonly IHubContext<GameHub> _signalRHub;
public DoComputerMoveAction(IHubContext<GameHub> signalRHub, ComputerPlayer computerPlayer)
{
_signalRHub = signalRHub;
_computerPlayer = computerPlayer;
}
public async Task Handle(OnGameCreatedNotification request, CancellationToken cancellationToken)
{
await _computerPlayer.DoComputerMove(request.ViewModel.ID).ConfigureAwait(false);
}
}
public class AddGameToLobbyAction : INotificationHandler<OnGameCreatedNotification>
{
private readonly IHubContext<GameHub> _signalRHub;
public AddGameToLobbyAction(IHubContext<GameHub> signalRHub)
{
_signalRHub = signalRHub;
}
public Task Handle(OnGameCreatedNotification notification, CancellationToken cancellationToken)
{
var lobbyEntry =
$@"<tr>
<td><a href=""/Home/Game/{notification.ViewModel.ID}"">{Resources.Resources.ResourceManager.GetString(notification.ViewModel.Variant.ToString())}</a></td>
<td>{Resources.Resources.ResourceManager.GetString(notification.ViewModel.GameStatus.ToString())}</td>
</tr>";
_signalRHub.Clients.Group("home").InvokeAsync("GameCreated", lobbyEntry);
return Task.CompletedTask;
}
}
The GameJoined actions:
public class OnGameJoinedNotification : INotification
{
public GameViewModel ViewModel { get; }
public Guid CurrentPlayerID { get; }
public OnGameJoinedNotification(GameViewModel viewModel, Guid currentPlayerID)
{
ViewModel = viewModel;
CurrentPlayerID = currentPlayerID;
}
}
public class RemoveGameFromLobbyAction : INotificationHandler<OnGameJoinedNotification>
{
private readonly IHubContext<GameHub> _signalRHub;
public RemoveGameFromLobbyAction(IHubContext<GameHub> signalRHub)
{
_signalRHub = signalRHub;
}
public Task Handle(OnGameJoinedNotification notification, CancellationToken cancellationToken)
{
_signalRHub.Clients.Group("home").InvokeAsync("GameJoined", notification.ViewModel.ID);
return Task.CompletedTask;
}
}
public class UpdateControlsAction : INotificationHandler<OnGameJoinedNotification>
{
private readonly IMediator _mediator;
private readonly IHubContext<GameHub> _signalRHub;
public UpdateControlsAction(IHubContext<GameHub> signalRHub, IMediator mediator)
{
_signalRHub = signalRHub;
_mediator = mediator;
}
public Task Handle(OnGameJoinedNotification notification, CancellationToken cancellationToken)
{
_signalRHub.Clients.All.InvokeAsync("AddClass", "join", "hide");
_signalRHub.Clients.Client(_mediator.Send(new GetClientConnectionMessage(notification.CurrentPlayerID)).Result).InvokeAsync("AddClass", notification.ViewModel.BlackPlayerID == notification.CurrentPlayerID ? "black-player-text" : "white-player-text", "bold");
_signalRHub.Clients.Client(_mediator.Send(new GetClientConnectionMessage(notification.CurrentPlayerID)).Result).InvokeAsync("AddClass", "new-game", "hide");
_signalRHub.Clients.Client(_mediator.Send(new GetClientConnectionMessage(notification.CurrentPlayerID)).Result).InvokeAsync("RemoveClass", "resign", "hide");
var clients = new List<IClientProxy>
{
_signalRHub.Clients.Client(_mediator.Send(new GetClientConnectionMessage(notification.ViewModel.BlackPlayerID)).Result),
_signalRHub.Clients.Client(_mediator.Send(new GetClientConnectionMessage(notification.ViewModel.WhitePlayerID)).Result)
};
foreach (var client in clients)
{
client.InvokeAsync("SetAttribute", "resign", "title", "Resign");
client.InvokeAsync("SetHtml", "#resign .sr-only", "Resign");
}
return Task.CompletedTask;
}
}
The Move actions:
public class OnMoveNotification : INotification
{
public GameViewModel ViewModel { get; }
public OnMoveNotification(GameViewModel viewModel)
{
ViewModel = viewModel;
}
}
public class DoComputerMoveAction : INotificationHandler<OnMoveNotification>
{
private readonly ComputerPlayer _computerPlayer;
private readonly IHubContext<GameHub> _signalRHub;
public DoComputerMoveAction(IHubContext<GameHub> signalRHub, ComputerPlayer computerPlayer)
{
_signalRHub = signalRHub;
_computerPlayer = computerPlayer;
}
public async Task Handle(OnMoveNotification request, CancellationToken cancellationToken)
{
await _computerPlayer.DoComputerMove(request.ViewModel.ID).ConfigureAwait(false);
}
}
public class UpdateMoveHistoryAction : INotificationHandler<OnMoveNotification>
{
private readonly IHubContext<GameHub> _signalRHub;
private readonly IMediator _mediator;
public UpdateMoveHistoryAction(IHubContext<GameHub> signalRHub, IMediator mediator)
{
_signalRHub = signalRHub;
_mediator = mediator;
}
public Task Handle(OnMoveNotification request, CancellationToken cancellationToken)
{
var lastMoveDate = _mediator.Send(new GetLastMoveDateMessage(request.ViewModel)).Result;
_signalRHub.Clients
.Group(request.ViewModel.ID.ToString())
.InvokeAsync("UpdateMoves", request.ViewModel.ID, lastMoveDate, ComponentGenerator.GetMoveControl(request.ViewModel.Turns));
return Task.CompletedTask;
}
}
public class UpdateOpponentStateAction : INotificationHandler<OnMoveNotification>
{
private readonly IHubContext<GameHub> _signalRHub;
private readonly IMediator _mediator;
public UpdateOpponentStateAction(IHubContext<GameHub> signalRHub, IMediator mediator)
{
_signalRHub = signalRHub;
_mediator = mediator;
}
public Task Handle(OnMoveNotification request, CancellationToken cancellationToken)
{
var lastMoveDate = _mediator.Send(new GetLastMoveDateMessage(request.ViewModel)).Result;
_signalRHub.Clients
.Group(request.ViewModel.ID.ToString())
.InvokeAsync("UpdateOpponentState", request.ViewModel.ID, lastMoveDate, request.ViewModel.CurrentPlayer.ToString(), request.ViewModel.GameStatus.ToString());
return Task.CompletedTask;
}
}
public class UpdateControlsAction : INotificationHandler<OnMoveNotification>
{
private readonly IHubContext<GameHub> _signalRHub;
private readonly IMediator _mediator;
public UpdateControlsAction(IHubContext<GameHub> signalRHub, IMediator mediator)
{
_signalRHub = signalRHub;
_mediator = mediator;
}
public Task Handle(OnMoveNotification request, CancellationToken cancellationToken)
{
var clients = new List<IClientProxy>
{
_signalRHub.Clients.Client(_mediator.Send(new GetClientConnectionMessage(request.ViewModel.BlackPlayerID)).Result),
_signalRHub.Clients.Client(_mediator.Send(new GetClientConnectionMessage(request.ViewModel.WhitePlayerID)).Result)
};
foreach (var client in clients)
{
if (request.ViewModel.Turns.Count == 1 && (request.ViewModel.Turns[0].BlackMove == null || request.ViewModel.Turns[0].WhiteMove == null) || request.ViewModel.GameStatus != Status.InProgress)
{
client.InvokeAsync("SetAttribute", "undo", "disabled", "");
}
else
{
client.InvokeAsync("RemoveAttribute", "undo", "disabled");
}
client.InvokeAsync(request.ViewModel.GameStatus != Status.InProgress ? "RemoveClass" : "AddClass", "new-game", "hide");
client.InvokeAsync(request.ViewModel.GameStatus != Status.InProgress ? "AddClass" : "RemoveClass", "resign", "hide");
}
return Task.CompletedTask;
}
}
This next action isn't used by the game, but rather publishes the data so another system can hook into mine and receive updates about games.
public class SignalGameStateAction : INotificationHandler<OnMoveNotification>
{
private readonly IHubContext<APIHub> _signalRHub;
public SignalGameStateAction(IHubContext<APIHub> signalRHub)
{
_signalRHub = signalRHub;
}
public Task Handle(OnMoveNotification request, CancellationToken cancellationToken)
{
var data = JsonConvert.SerializeObject(request.ViewModel);
_signalRHub.Clients.All.InvokeAsync("GameChanged", data);
return Task.CompletedTask;
}
}
And finally, I really hate this last one, but I don't see any other way in SignalR Core to publish a message to a group with exceptions by connection ID. They have this functionality in SignalR non-Core, so hopefully soon...
public class UpdateBoardAction : INotificationHandler<OnMoveNotification>
{
private readonly IHubContext<GameHub> _signalRHub;
private readonly Database.Context _context;
private readonly IMediator _mediator;
public UpdateBoardAction(IHubContext<GameHub> signalRHub, Database.Context context, IMediator mediator)
{
_signalRHub = signalRHub;
_context = context;
_mediator = mediator;
}
private string GetClientConnection(Guid id)
{
return _context.Players.Find(id).ConnectionID;
}
Dictionary<string, object> GetViewData(Guid localPlayerID, Player orientation)
{
return new Dictionary<string, object>
{
["playerID"] = localPlayerID,
["orientation"] = orientation
};
}
public Task Handle(OnMoveNotification request, CancellationToken cancellationToken)
{
var lastMoveDate = _mediator.Send(new GetLastMoveDateMessage(request.ViewModel)).Result;
var blackConnection = GetClientConnection(request.ViewModel.BlackPlayerID);
var whiteConnection = GetClientConnection(request.ViewModel.WhitePlayerID);
if (request.ViewModel.BlackPlayerID != ComputerPlayer.ComputerPlayerID)
{
_signalRHub.Clients.Client(blackConnection).InvokeAsync("UpdateBoard", request.ViewModel.ID, lastMoveDate,
ComponentGenerator.GetBoard(request.ViewModel, GetViewData(request.ViewModel.BlackPlayerID, Player.Black)),
ComponentGenerator.GetBoard(request.ViewModel, GetViewData(request.ViewModel.BlackPlayerID, Player.White)));
}
if (request.ViewModel.WhitePlayerID != ComputerPlayer.ComputerPlayerID)
{
_signalRHub.Clients.Client(whiteConnection).InvokeAsync("UpdateBoard", request.ViewModel.ID, lastMoveDate,
ComponentGenerator.GetBoard(request.ViewModel, GetViewData(request.ViewModel.WhitePlayerID, Player.Black)),
ComponentGenerator.GetBoard(request.ViewModel, GetViewData(request.ViewModel.WhitePlayerID, Player.White)));
}
_signalRHub.Groups.RemoveAsync(blackConnection, request.ViewModel.ID.ToString()).Wait();
_signalRHub.Groups.RemoveAsync(whiteConnection, request.ViewModel.ID.ToString()).Wait();
_signalRHub.Clients
.Group(request.ViewModel.ID.ToString())
.InvokeAsync("UpdateBoard", request.ViewModel.ID, lastMoveDate,
ComponentGenerator.GetBoard(request.ViewModel, GetViewData(Guid.Empty, Player.Black)),
ComponentGenerator.GetBoard(request.ViewModel, GetViewData(Guid.Empty, Player.White)));
_signalRHub.Groups.AddAsync(blackConnection, request.ViewModel.ID.ToString()).Wait();
_signalRHub.Groups.AddAsync(whiteConnection, request.ViewModel.ID.ToString()).Wait();
return Task.CompletedTask;
}
}
And the GameCompleted actions:
public class OnGameCompletedNotification : INotification
{
public GameViewModel ViewModel { get; }
public OnGameCompletedNotification(GameViewModel viewModel)
{
ViewModel = viewModel;
}
}
public class UpdateControlsAction : INotificationHandler<OnGameCompletedNotification>
{
private readonly IHubContext<GameHub> _signalRHub;
private readonly IMediator _mediator;
public UpdateControlsAction(IHubContext<GameHub> signalRHub, IMediator mediator)
{
_signalRHub = signalRHub;
_mediator = mediator;
}
public Task Handle(OnGameCompletedNotification request, CancellationToken cancellationToken)
{
var clients = new List<IClientProxy>
{
_signalRHub.Clients.Client(_mediator.Send(new GetClientConnectionMessage(request.ViewModel.BlackPlayerID)).Result),
_signalRHub.Clients.Client(_mediator.Send(new GetClientConnectionMessage(request.ViewModel.WhitePlayerID)).Result)
};
foreach (var client in clients)
{
client.InvokeAsync("SetAttribute", "undo", "disabled", "");
client.InvokeAsync("RemoveClass", "new-game", "hide");
client.InvokeAsync("AddClass", "resign", "hide");
}
return Task.CompletedTask;
}
}
My main concerns here are:
A) Am I following best web practices, including my MVC structure?
B) Is my choice of MediatR for the action system good?
C) See anything else I could change for the better?