Before changing Piece[,] to Dictionary<string,Piece>

This commit is contained in:
2021-07-26 06:28:56 -05:00
parent f8f779e84c
commit 178cb00253
73 changed files with 1537 additions and 1418 deletions

View File

@@ -1,6 +1,5 @@
using Gameboard.ShogiUI.Sockets.Managers;
using Gameboard.ShogiUI.Sockets.Repositories;
using Gameboard.ShogiUI.Sockets.Repositories.RepositoryManagers;
using Gameboard.ShogiUI.Sockets.ServiceModels.Api.Messages;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -14,14 +13,14 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
[Route("[controller]")]
public class GameController : ControllerBase
{
private readonly IGameboardRepositoryManager manager;
private readonly ISocketCommunicationManager communicationManager;
private readonly IGameboardManager manager;
private readonly ISocketConnectionManager communicationManager;
private readonly IGameboardRepository repository;
public GameController(
IGameboardRepository repository,
IGameboardRepositoryManager manager,
ISocketCommunicationManager communicationManager)
IGameboardManager manager,
ISocketConnectionManager communicationManager)
{
this.manager = manager;
this.communicationManager = communicationManager;

View File

@@ -1,6 +1,5 @@
using Gameboard.ShogiUI.Sockets.Managers;
using Gameboard.ShogiUI.Sockets.Repositories;
using Gameboard.ShogiUI.Sockets.Repositories.RepositoryManagers;
using Gameboard.ShogiUI.Sockets.ServiceModels.Api.Messages;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -17,13 +16,13 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
{
private readonly ILogger<SocketController> logger;
private readonly ISocketTokenManager tokenManager;
private readonly IGameboardRepositoryManager gameboardManager;
private readonly IGameboardManager gameboardManager;
private readonly IGameboardRepository gameboardRepository;
public SocketController(
ILogger<SocketController> logger,
ISocketTokenManager tokenManager,
IGameboardRepositoryManager gameboardManager,
IGameboardManager gameboardManager,
IGameboardRepository gameboardRepository)
{
this.logger = logger;

View File

@@ -0,0 +1,64 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
using System;
using System.Text;
using System.Text.RegularExpressions;
namespace Gameboard.ShogiUI.Sockets.Extensions
{
public static class ModelExtensions
{
public static string GetShortName(this Models.Piece self)
{
var name = self.WhichPiece switch
{
WhichPiece.King => " K ",
WhichPiece.GoldGeneral => " G ",
WhichPiece.SilverGeneral => self.IsPromoted ? "^S " : " S ",
WhichPiece.Bishop => self.IsPromoted ? "^B " : " B ",
WhichPiece.Rook => self.IsPromoted ? "^R " : " R ",
WhichPiece.Knight => self.IsPromoted ? "^k " : " k ",
WhichPiece.Lance => self.IsPromoted ? "^L " : " L ",
WhichPiece.Pawn => self.IsPromoted ? "^P " : " P ",
_ => " ? ",
};
if (self.Owner == WhichPlayer.Player2)
name = Regex.Replace(name, @"([^\s]+)\s", "$1.");
return name;
}
public static void PrintStateAsAscii(this Models.Shogi self)
{
var builder = new StringBuilder();
builder.Append(" Player 2(.)");
builder.AppendLine();
for (var y = 0; y < 9; y++)
{
builder.Append("- ");
for (var x = 0; x < 8; x++) builder.Append("- - ");
builder.Append("- -");
builder.AppendLine();
builder.Append('|');
for (var x = 0; x < 9; x++)
{
var piece = self.Board[y, x];
if (piece == null)
{
builder.Append(" ");
}
else
{
builder.AppendFormat("{0}", piece.GetShortName());
}
builder.Append('|');
}
builder.AppendLine();
}
builder.Append("- ");
for (var x = 0; x < 8; x++) builder.Append("- - ");
builder.Append("- -");
builder.AppendLine();
builder.Append(" Player 1");
Console.WriteLine(builder.ToString());
}
}
}

View File

@@ -8,14 +8,7 @@
</PropertyGroup>
<ItemGroup>
<None Remove="Repositories\CouchModels\Readme.md" />
</ItemGroup>
<ItemGroup>
<Compile Include="Repositories\CouchModels\Readme.md" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="10.3.0" />
<PackageReference Include="IdentityModel" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.AzureAD.UI" Version="5.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.2" />
@@ -26,8 +19,8 @@
<ItemGroup>
<ProjectReference Include="..\CouchDB\CouchDB.csproj" />
<ProjectReference Include="..\Gameboard.ShogiUI.Rules\Gameboard.ShogiUI.Rules.csproj" />
<ProjectReference Include="..\Gameboard.ShogiUI.Sockets.ServiceModels\Gameboard.ShogiUI.Sockets.ServiceModels.csproj" />
<ProjectReference Include="..\PathFinding\PathFinding.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,33 @@
using Gameboard.ShogiUI.Sockets.Models;
using System.Collections.Concurrent;
namespace Gameboard.ShogiUI.Sockets.Managers
{
public interface IActiveSessionManager
{
void Add(Session session);
Session? Get(string sessionName);
}
// TODO: Consider moving this class' functionality into the ConnectionManager class.
public class ActiveSessionManager : IActiveSessionManager
{
private readonly ConcurrentDictionary<string, Session> Sessions;
public ActiveSessionManager()
{
Sessions = new ConcurrentDictionary<string, Session>();
}
public void Add(Session session) => Sessions.TryAdd(session.Name, session);
public Session? Get(string sessionName)
{
if (Sessions.TryGetValue(sessionName, out var session))
{
return session;
}
return null;
}
}
}

View File

@@ -1,32 +0,0 @@
using Gameboard.ShogiUI.Rules;
using System.Collections.Concurrent;
namespace Gameboard.ShogiUI.Sockets.Managers
{
public interface IBoardManager
{
void Add(string sessionName, ShogiBoard board);
ShogiBoard? Get(string sessionName);
}
public class BoardManager : IBoardManager
{
private readonly ConcurrentDictionary<string, ShogiBoard> Boards;
public BoardManager()
{
Boards = new ConcurrentDictionary<string, ShogiBoard>();
}
public void Add(string sessionName, ShogiBoard board) => Boards.TryAdd(sessionName, board);
public ShogiBoard? Get(string sessionName)
{
if (Boards.TryGetValue(sessionName, out var board))
{
return board;
}
return null;
}
}
}

View File

@@ -1,5 +1,4 @@
using Gameboard.ShogiUI.Sockets.Models;
using Gameboard.ShogiUI.Sockets.Repositories.RepositoryManagers;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages;
using System.Threading.Tasks;
@@ -14,20 +13,20 @@ namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers
// It can be an API route and still tell socket connections about the new session.
public class CreateGameHandler : ICreateGameHandler
{
private readonly IGameboardRepositoryManager manager;
private readonly ISocketCommunicationManager communicationManager;
private readonly IGameboardManager manager;
private readonly ISocketConnectionManager connectionManager;
public CreateGameHandler(
ISocketCommunicationManager communicationManager,
IGameboardRepositoryManager manager)
ISocketConnectionManager communicationManager,
IGameboardManager manager)
{
this.manager = manager;
this.communicationManager = communicationManager;
this.connectionManager = communicationManager;
}
public async Task Handle(CreateGameRequest request, string userName)
{
var model = new Session(request.GameName, request.IsPrivate, userName);
var model = new SessionMetadata(request.GameName, request.IsPrivate, userName, null);
var success = await manager.CreateSession(model);
if (!success)
@@ -36,7 +35,7 @@ namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers
{
Error = "Unable to create game with this name."
};
await communicationManager.BroadcastToPlayers(error, userName);
await connectionManager.BroadcastToPlayers(error, userName);
}
var response = new CreateGameResponse(request.Action)
@@ -46,8 +45,8 @@ namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers
};
var task = request.IsPrivate
? communicationManager.BroadcastToPlayers(response, userName)
: communicationManager.BroadcastToAll(response);
? connectionManager.BroadcastToPlayers(response, userName)
: connectionManager.BroadcastToAll(response);
await task;
}

View File

@@ -11,10 +11,10 @@ namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers
public class JoinByCodeHandler : IJoinByCodeHandler
{
private readonly IGameboardRepository repository;
private readonly ISocketCommunicationManager communicationManager;
private readonly ISocketConnectionManager communicationManager;
public JoinByCodeHandler(
ISocketCommunicationManager communicationManager,
ISocketConnectionManager communicationManager,
IGameboardRepository repository)
{
this.repository = repository;

View File

@@ -1,5 +1,5 @@
using Gameboard.ShogiUI.Sockets.Repositories;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers
@@ -10,40 +10,34 @@ namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers
}
public class JoinGameHandler : IJoinGameHandler
{
private readonly IGameboardRepository gameboardRepository;
private readonly ISocketCommunicationManager communicationManager;
private readonly IGameboardManager gameboardManager;
private readonly ISocketConnectionManager connectionManager;
public JoinGameHandler(
ISocketCommunicationManager communicationManager,
IGameboardRepository gameboardRepository)
ISocketConnectionManager communicationManager,
IGameboardManager gameboardManager)
{
this.gameboardRepository = gameboardRepository;
this.communicationManager = communicationManager;
this.gameboardManager = gameboardManager;
this.connectionManager = communicationManager;
}
public async Task Handle(JoinGameRequest request, string userName)
{
//var request = JsonConvert.DeserializeObject<JoinGameRequest>(json);
var joinSucceeded = await gameboardManager.AssignPlayer2ToSession(request.GameName, userName);
//var joinSucceeded = await gameboardRepository.PutJoinPublicSession(new PutJoinPublicSession
//{
// PlayerName = userName,
// SessionName = request.GameName
//});
//var response = new JoinGameResponse(ClientAction.JoinGame)
//{
// PlayerName = userName,
// GameName = request.GameName
//};
//if (joinSucceeded)
//{
// await communicationManager.BroadcastToAll(response);
//}
//else
//{
// response.Error = "Game is full.";
// await communicationManager.BroadcastToPlayers(response, userName);
//}
var response = new JoinGameResponse(ClientAction.JoinGame)
{
PlayerName = userName,
GameName = request.GameName
};
if (joinSucceeded)
{
await connectionManager.BroadcastToAll(response);
}
else
{
response.Error = "Game is full or does not exist.";
await connectionManager.BroadcastToPlayers(response, userName);
}
}
}
}

View File

@@ -11,15 +11,13 @@ namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers
Task Handle(ListGamesRequest request, string userName);
}
// TODO: This doesn't need to be a socket action.
// It can be an HTTP route.
public class ListGamesHandler : IListGamesHandler
{
private readonly ISocketCommunicationManager communicationManager;
private readonly ISocketConnectionManager communicationManager;
private readonly IGameboardRepository repository;
public ListGamesHandler(
ISocketCommunicationManager communicationManager,
ISocketConnectionManager communicationManager,
IGameboardRepository repository)
{
this.communicationManager = communicationManager;
@@ -28,12 +26,12 @@ namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers
public async Task Handle(ListGamesRequest _, string userName)
{
var sessions = await repository.ReadSessions();
var games = sessions.Select(s => s.ToServiceModel()); // yuck
var sessions = await repository.ReadSessionMetadatas();
var games = sessions.Select(s => new Game(s.Name, s.Player1, s.Player2)).ToList();
var response = new ListGamesResponse(ClientAction.ListGames)
{
Games = games.ToList()
Games = games
};
await communicationManager.BroadcastToPlayers(response, userName);

View File

@@ -1,4 +1,4 @@
using Gameboard.ShogiUI.Rules;
using Gameboard.ShogiUI.Sockets.Models;
using Gameboard.ShogiUI.Sockets.Repositories;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
@@ -20,14 +20,14 @@ namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers
{
private readonly ILogger<LoadGameHandler> logger;
private readonly IGameboardRepository gameboardRepository;
private readonly ISocketCommunicationManager communicationManager;
private readonly IBoardManager boardManager;
private readonly ISocketConnectionManager communicationManager;
private readonly IActiveSessionManager boardManager;
public LoadGameHandler(
ILogger<LoadGameHandler> logger,
ISocketCommunicationManager communicationManager,
ISocketConnectionManager communicationManager,
IGameboardRepository gameboardRepository,
IBoardManager boardManager)
IActiveSessionManager boardManager)
{
this.logger = logger;
this.gameboardRepository = gameboardRepository;
@@ -37,10 +37,7 @@ namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers
public async Task Handle(LoadGameRequest request, string userName)
{
var readSession = gameboardRepository.ReadSession(request.GameName);
var readStates = gameboardRepository.ReadBoardStates(request.GameName);
var sessionModel = await readSession;
var sessionModel = await gameboardRepository.ReadSession(request.GameName);
if (sessionModel == null)
{
logger.LogWarning("{action} - {user} was unable to load session named {session}.", ClientAction.LoadGame, userName, request.GameName);
@@ -50,18 +47,13 @@ namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers
}
communicationManager.SubscribeToGame(sessionModel, userName);
var boardStates = await readStates;
var moveModels = boardStates
.Where(_ => _.Move != null)
.Select(_ => _.Move!.ToRulesModel())
.ToList();
var shogiBoard = new ShogiBoard(moveModels);
boardManager.Add(sessionModel.Name, shogiBoard);
boardManager.Add(sessionModel);
var response = new LoadGameResponse(ClientAction.LoadGame)
{
Game = sessionModel.ToServiceModel(),
BoardState = new Models.BoardState(shogiBoard).ToServiceModel()
Game = new SessionMetadata(sessionModel).ToServiceModel(),
BoardState = sessionModel.Shogi.ToServiceModel(),
MoveHistory = sessionModel.Shogi.MoveHistory.Select(_ => _.ToServiceModel()).ToList()
};
await communicationManager.BroadcastToPlayers(response, userName);
}

View File

@@ -1,7 +1,6 @@
using Gameboard.ShogiUI.Sockets.Models;
using Gameboard.ShogiUI.Sockets.Repositories;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages;
using Newtonsoft.Json;
using System.Threading.Tasks;
@@ -13,35 +12,45 @@ namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers
}
public class MoveHandler : IMoveHandler
{
private readonly IBoardManager boardManager;
private readonly IGameboardRepository gameboardRepository;
private readonly ISocketCommunicationManager communicationManager;
private readonly IActiveSessionManager boardManager;
private readonly IGameboardManager gameboardManager;
private readonly ISocketConnectionManager communicationManager;
public MoveHandler(
IBoardManager boardManager,
ISocketCommunicationManager communicationManager,
IGameboardRepository gameboardRepository)
IActiveSessionManager boardManager,
ISocketConnectionManager communicationManager,
IGameboardManager gameboardManager)
{
this.boardManager = boardManager;
this.gameboardRepository = gameboardRepository;
this.gameboardManager = gameboardManager;
this.communicationManager = communicationManager;
}
public async Task Handle(MoveRequest request, string userName)
{
//var request = JsonConvert.DeserializeObject<Service.Messages.MoveRequest>(json);
//var moveModel = new Move(request.Move);
//var board = boardManager.Get(request.GameName);
//if (board == null)
//{
// // TODO: Find a flow for this
// var response = new Service.Messages.MoveResponse(Service.Types.ClientAction.Move)
// {
// Error = $"Game isn't loaded. Send a message with the {Service.Types.ClientAction.LoadGame} action first."
// };
// await communicationManager.BroadcastToPlayers(response, userName);
Move moveModel;
if (request.Move.PieceFromCaptured.HasValue)
{
moveModel = new Move(request.Move.PieceFromCaptured.Value, request.Move.To);
}
else
{
moveModel = new Move(request.Move.From!, request.Move.To, request.Move.IsPromotion);
}
var board = boardManager.Get(request.GameName);
if (board == null)
{
// TODO: Find a flow for this
var response = new MoveResponse(ServiceModels.Socket.Types.ClientAction.Move)
{
Error = $"Game isn't loaded. Send a message with the {ServiceModels.Socket.Types.ClientAction.LoadGame} action first."
};
await communicationManager.BroadcastToPlayers(response, userName);
}
//}
//var boardMove = moveModel.ToBoardModel();
//var moveSuccess = board.Move(boardMove);
//if (moveSuccess)
//{

View File

@@ -1,24 +1,28 @@
using Gameboard.ShogiUI.Sockets.Models;
using Gameboard.ShogiUI.Sockets.Repositories;
using System;
using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.Repositories.RepositoryManagers
namespace Gameboard.ShogiUI.Sockets.Managers
{
public interface IGameboardRepositoryManager
public interface IGameboardManager
{
Task<string> CreateGuestUser();
Task<bool> IsPlayer1(string sessionName, string playerName);
bool IsGuest(string playerName);
Task<bool> CreateSession(Session session);
Task<bool> CreateSession(SessionMetadata session);
Task<Session?> ReadSession(string gameName);
Task<bool> UpdateSession(Session session);
Task<bool> AssignPlayer2ToSession(string sessionName, string userName);
}
public class GameboardRepositoryManager : IGameboardRepositoryManager
public class GameboardManager : IGameboardManager
{
private const int MaxTries = 3;
private const string GuestPrefix = "Guest-";
private readonly IGameboardRepository repository;
public GameboardRepositoryManager(IGameboardRepository repository)
public GameboardManager(IGameboardRepository repository)
{
this.repository = repository;
}
@@ -53,19 +57,44 @@ namespace Gameboard.ShogiUI.Sockets.Repositories.RepositoryManagers
//{
// return await repository.PostJoinCode(sessionName, playerName);
//}
return null;
return string.Empty;
}
public async Task<bool> CreateSession(Session session)
public Task<bool> CreateSession(SessionMetadata session)
{
var success = await repository.CreateSession(session);
if (success)
{
return await repository.CreateBoardState(session.Name, new BoardState(), null);
}
return false;
return repository.CreateSession(session);
}
public bool IsGuest(string playerName) => playerName.StartsWith(GuestPrefix);
public Task<Session?> ReadSession(string sessionName)
{
return repository.ReadSession(sessionName);
}
/// <summary>
/// Saves the session to storage.
/// </summary>
/// <param name="session">The session to save.</param>
/// <returns>True if the session was saved successfully.</returns>
public Task<bool> UpdateSession(Session session)
{
return repository.UpdateSession(session);
}
public async Task<bool> AssignPlayer2ToSession(string sessionName, string userName)
{
var isSuccess = false;
var session = await repository.ReadSession(sessionName);
if (session != null && !session.IsPrivate && string.IsNullOrEmpty(session.Player2))
{
session.SetPlayer2(userName);
if (await repository.UpdateSession(session))
{
isSuccess = true;
}
}
return isSuccess;
}
}
}

View File

@@ -1,140 +0,0 @@
using Gameboard.ShogiUI.Sockets.Extensions;
using Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers;
using Gameboard.ShogiUI.Sockets.Managers.Utility;
using Gameboard.ShogiUI.Sockets.Models;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Interfaces;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.WebSockets;
using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.Managers
{
public interface ISocketCommunicationManager
{
Task BroadcastToAll(IResponse response);
//Task BroadcastToGame(string gameName, IResponse response);
//Task BroadcastToGame(string gameName, IResponse forPlayer1, IResponse forPlayer2);
void SubscribeToGame(Session session, string playerName);
void SubscribeToBroadcast(WebSocket socket, string playerName);
void UnsubscribeFromBroadcastAndGames(string playerName);
void UnsubscribeFromGame(string gameName, string playerName);
Task BroadcastToPlayers(IResponse response, params string[] playerNames);
}
public class SocketCommunicationManager : ISocketCommunicationManager
{
/// <summary>Dictionary key is player name.</summary>
private readonly ConcurrentDictionary<string, WebSocket> connections;
/// <summary>Dictionary key is game name.</summary>
private readonly ConcurrentDictionary<string, Session> sessions;
private readonly ILogger<SocketCommunicationManager> logger;
public SocketCommunicationManager(ILogger<SocketCommunicationManager> logger)
{
this.logger = logger;
connections = new ConcurrentDictionary<string, WebSocket>();
sessions = new ConcurrentDictionary<string, Session>();
}
public void SubscribeToBroadcast(WebSocket socket, string playerName)
{
connections.TryAdd(playerName, socket);
}
public void UnsubscribeFromBroadcastAndGames(string playerName)
{
connections.TryRemove(playerName, out _);
foreach (var kvp in sessions)
{
var sessionName = kvp.Key;
UnsubscribeFromGame(sessionName, playerName);
}
}
/// <summary>
/// Unsubscribes the player from their current game, then subscribes to the new game.
/// </summary>
public void SubscribeToGame(Session session, string playerName)
{
// Unsubscribe from any other games
foreach (var kvp in sessions)
{
var gameNameKey = kvp.Key;
UnsubscribeFromGame(gameNameKey, playerName);
}
// Subscribe
if (connections.TryGetValue(playerName, out var socket))
{
var s = sessions.GetOrAdd(session.Name, session);
s.Subscriptions.TryAdd(playerName, socket);
}
}
public void UnsubscribeFromGame(string gameName, string playerName)
{
if (sessions.TryGetValue(gameName, out var s))
{
s.Subscriptions.TryRemove(playerName, out _);
if (s.Subscriptions.IsEmpty) sessions.TryRemove(gameName, out _);
}
}
public async Task BroadcastToPlayers(IResponse response, params string[] playerNames)
{
var tasks = new List<Task>(playerNames.Length);
foreach (var name in playerNames)
{
if (connections.TryGetValue(name, out var socket))
{
var serialized = JsonConvert.SerializeObject(response);
logger.LogInformation("Response to {0} \n{1}\n", name, serialized);
tasks.Add(socket.SendTextAsync(serialized));
}
}
await Task.WhenAll(tasks);
}
public Task BroadcastToAll(IResponse response)
{
var message = JsonConvert.SerializeObject(response);
logger.LogInformation($"Broadcasting\n{0}", message);
var tasks = new List<Task>(connections.Count);
foreach (var kvp in connections)
{
var socket = kvp.Value;
tasks.Add(socket.SendTextAsync(message));
}
return Task.WhenAll(tasks);
}
//public Task BroadcastToGame(string gameName, IResponse forPlayer1, IResponse forPlayer2)
//{
// if (sessions.TryGetValue(gameName, out var session))
// {
// var serialized1 = JsonConvert.SerializeObject(forPlayer1);
// var serialized2 = JsonConvert.SerializeObject(forPlayer2);
// return Task.WhenAll(
// session.SendToPlayer1(serialized1),
// session.SendToPlayer2(serialized2));
// }
// return Task.CompletedTask;
//}
//public Task BroadcastToGame(string gameName, IResponse messageForAllPlayers)
//{
// if (sessions.TryGetValue(gameName, out var session))
// {
// var serialized = JsonConvert.SerializeObject(messageForAllPlayers);
// return session.Broadcast(serialized);
// }
// return Task.CompletedTask;
//}
}
}

View File

@@ -1,13 +1,10 @@
using Gameboard.ShogiUI.Sockets.Extensions;
using Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers;
using Gameboard.ShogiUI.Sockets.Managers.Utility;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
using Microsoft.AspNetCore.Http;
using Gameboard.ShogiUI.Sockets.Models;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Interfaces;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System;
using System.Net;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.WebSockets;
using System.Threading.Tasks;
@@ -15,127 +12,127 @@ namespace Gameboard.ShogiUI.Sockets.Managers
{
public interface ISocketConnectionManager
{
Task HandleSocketRequest(HttpContext context);
Task BroadcastToAll(IResponse response);
//Task BroadcastToGame(string gameName, IResponse response);
//Task BroadcastToGame(string gameName, IResponse forPlayer1, IResponse forPlayer2);
void SubscribeToGame(Session session, string playerName);
void SubscribeToBroadcast(WebSocket socket, string playerName);
void UnsubscribeFromBroadcastAndGames(string playerName);
void UnsubscribeFromGame(string gameName, string playerName);
Task BroadcastToPlayers(IResponse response, params string[] playerNames);
}
/// <summary>
/// Retains all active socket connections and provides convenient methods for sending messages to clients.
/// </summary>
public class SocketConnectionManager : ISocketConnectionManager
{
/// <summary>Dictionary key is player name.</summary>
private readonly ConcurrentDictionary<string, WebSocket> connections;
/// <summary>Dictionary key is game name.</summary>
private readonly ConcurrentDictionary<string, Session> sessions;
private readonly ILogger<SocketConnectionManager> logger;
private readonly ISocketCommunicationManager communicationManager;
private readonly ISocketTokenManager tokenManager;
private readonly ICreateGameHandler createGameHandler;
private readonly IJoinByCodeHandler joinByCodeHandler;
private readonly IJoinGameHandler joinGameHandler;
private readonly IListGamesHandler listGamesHandler;
private readonly ILoadGameHandler loadGameHandler;
private readonly IMoveHandler moveHandler;
public SocketConnectionManager(
ILogger<SocketConnectionManager> logger,
ISocketCommunicationManager communicationManager,
ISocketTokenManager tokenManager,
ICreateGameHandler createGameHandler,
IJoinByCodeHandler joinByCodeHandler,
IJoinGameHandler joinGameHandler,
IListGamesHandler listGamesHandler,
ILoadGameHandler loadGameHandler,
IMoveHandler moveHandler) : base()
public SocketConnectionManager(ILogger<SocketConnectionManager> logger)
{
this.logger = logger;
this.communicationManager = communicationManager;
this.tokenManager = tokenManager;
this.createGameHandler = createGameHandler;
this.joinByCodeHandler = joinByCodeHandler;
this.joinGameHandler = joinGameHandler;
this.listGamesHandler = listGamesHandler;
this.loadGameHandler = loadGameHandler;
this.moveHandler = moveHandler;
connections = new ConcurrentDictionary<string, WebSocket>();
sessions = new ConcurrentDictionary<string, Session>();
}
public async Task HandleSocketRequest(HttpContext context)
public void SubscribeToBroadcast(WebSocket socket, string playerName)
{
var hasToken = context.Request.Query.Keys.Contains("token");
if (hasToken)
connections.TryAdd(playerName, socket);
}
public void UnsubscribeFromBroadcastAndGames(string playerName)
{
connections.TryRemove(playerName, out _);
foreach (var kvp in sessions)
{
var oneTimeToken = context.Request.Query["token"][0];
var tokenAsGuid = Guid.Parse(oneTimeToken);
var userName = tokenManager.GetUsername(tokenAsGuid);
if (userName != null)
var sessionName = kvp.Key;
UnsubscribeFromGame(sessionName, playerName);
}
}
/// <summary>
/// Unsubscribes the player from their current game, then subscribes to the new game.
/// </summary>
public void SubscribeToGame(Session session, string playerName)
{
// Unsubscribe from any other games
foreach (var kvp in sessions)
{
var gameNameKey = kvp.Key;
UnsubscribeFromGame(gameNameKey, playerName);
}
// Subscribe
if (connections.TryGetValue(playerName, out var socket))
{
var s = sessions.GetOrAdd(session.Name, session);
s.Subscriptions.TryAdd(playerName, socket);
}
}
public void UnsubscribeFromGame(string gameName, string playerName)
{
if (sessions.TryGetValue(gameName, out var s))
{
s.Subscriptions.TryRemove(playerName, out _);
if (s.Subscriptions.IsEmpty) sessions.TryRemove(gameName, out _);
}
}
public async Task BroadcastToPlayers(IResponse response, params string[] playerNames)
{
var tasks = new List<Task>(playerNames.Length);
foreach (var name in playerNames)
{
if (connections.TryGetValue(name, out var socket))
{
var socket = await context.WebSockets.AcceptWebSocketAsync();
var serialized = JsonConvert.SerializeObject(response);
logger.LogInformation("Response to {0} \n{1}\n", name, serialized);
tasks.Add(socket.SendTextAsync(serialized));
communicationManager.SubscribeToBroadcast(socket, userName);
while (!socket.CloseStatus.HasValue)
{
try
{
var message = await socket.ReceiveTextAsync();
if (string.IsNullOrWhiteSpace(message)) continue;
logger.LogInformation("Request \n{0}\n", message);
var request = JsonConvert.DeserializeObject<Request>(message);
if (!Enum.IsDefined(typeof(ClientAction), request.Action))
{
await socket.SendTextAsync("Error: Action not recognized.");
continue;
}
switch (request.Action)
{
case ClientAction.ListGames:
{
var req = JsonConvert.DeserializeObject<ListGamesRequest>(message);
await listGamesHandler.Handle(req, userName);
break;
}
case ClientAction.CreateGame:
{
var req = JsonConvert.DeserializeObject<CreateGameRequest>(message);
await createGameHandler.Handle(req, userName);
break;
}
case ClientAction.JoinGame:
{
var req = JsonConvert.DeserializeObject<JoinGameRequest>(message);
await joinGameHandler.Handle(req, userName);
break;
}
case ClientAction.JoinByCode:
{
var req = JsonConvert.DeserializeObject<JoinByCodeRequest>(message);
await joinByCodeHandler.Handle(req, userName);
break;
}
case ClientAction.LoadGame:
{
var req = JsonConvert.DeserializeObject<LoadGameRequest>(message);
await loadGameHandler.Handle(req, userName);
break;
}
case ClientAction.Move:
{
var req = JsonConvert.DeserializeObject<MoveRequest>(message);
await moveHandler.Handle(req, userName);
break;
}
}
}
catch (OperationCanceledException ex)
{
logger.LogError(ex.Message);
}
catch (WebSocketException ex)
{
logger.LogInformation($"{nameof(WebSocketException)} in {nameof(SocketCommunicationManager)}.");
logger.LogInformation("Probably tried writing to a closed socket.");
logger.LogError(ex.Message);
}
}
communicationManager.UnsubscribeFromBroadcastAndGames(userName);
return;
}
}
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
return;
await Task.WhenAll(tasks);
}
public Task BroadcastToAll(IResponse response)
{
var message = JsonConvert.SerializeObject(response);
logger.LogInformation($"Broadcasting\n{0}", message);
var tasks = new List<Task>(connections.Count);
foreach (var kvp in connections)
{
var socket = kvp.Value;
tasks.Add(socket.SendTextAsync(message));
}
return Task.WhenAll(tasks);
}
//public Task BroadcastToGame(string gameName, IResponse forPlayer1, IResponse forPlayer2)
//{
// if (sessions.TryGetValue(gameName, out var session))
// {
// var serialized1 = JsonConvert.SerializeObject(forPlayer1);
// var serialized2 = JsonConvert.SerializeObject(forPlayer2);
// return Task.WhenAll(
// session.SendToPlayer1(serialized1),
// session.SendToPlayer2(serialized2));
// }
// return Task.CompletedTask;
//}
//public Task BroadcastToGame(string gameName, IResponse messageForAllPlayers)
//{
// if (sessions.TryGetValue(gameName, out var session))
// {
// var serialized = JsonConvert.SerializeObject(messageForAllPlayers);
// return session.Broadcast(serialized);
// }
// return Task.CompletedTask;
//}
}
}

View File

@@ -1,66 +0,0 @@
using Gameboard.ShogiUI.Rules;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using ServiceTypes = Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
namespace Gameboard.ShogiUI.Sockets.Models
{
public class BoardState
{
// TODO: Create a custom 2D array implementation which removes the (x,y) or (y,x) ambiguity.
public Piece?[,] Board { get; }
public IReadOnlyCollection<Piece> Player1Hand { get; }
public IReadOnlyCollection<Piece> Player2Hand { get; }
/// <summary>
/// Move is null in the first BoardState of a Session, before any moves have been made.
/// </summary>
public Move? Move { get; }
public BoardState() : this(new ShogiBoard()) { }
public BoardState(Piece?[,] board, IList<Piece> player1Hand, ICollection<Piece> player2Hand, Move move)
{
Board = board;
Player1Hand = new ReadOnlyCollection<Piece>(player1Hand);
}
public BoardState(ShogiBoard shogi)
{
Board = new Piece[9, 9];
for (var x = 0; x < 9; x++)
for (var y = 0; y < 9; y++)
{
var piece = shogi.Board[x, y];
if (piece != null)
{
Board[x, y] = new Piece(piece);
}
}
Player1Hand = shogi.Hands[WhichPlayer.Player1].Select(_ => new Piece(_)).ToList();
Player2Hand = shogi.Hands[WhichPlayer.Player2].Select(_ => new Piece(_)).ToList();
Move = new Move(shogi.MoveHistory[^1]);
}
public ServiceTypes.BoardState ToServiceModel()
{
var board = new ServiceTypes.Piece[9, 9];
for (var x = 0; x < 9; x++)
for (var y = 0; y < 9; y++)
{
var piece = Board[x, y];
if (piece != null)
{
board[x, y] = piece.ToServiceModel();
}
}
return new ServiceTypes.BoardState
{
Board = board,
Player1Hand = Player1Hand.Select(_ => _.ToServiceModel()).ToList(),
Player2Hand = Player2Hand.Select(_ => _.ToServiceModel()).ToList()
};
}
}
}

View File

@@ -1,41 +0,0 @@
using System;
using System.Text.RegularExpressions;
namespace Gameboard.ShogiUI.Sockets.Models
{
public class Coords
{
private const string BoardNotationRegex = @"(?<file>[A-I])(?<rank>[1-9])";
private const char A = 'A';
public int X { get; }
public int Y { get; }
public Coords(int x, int y)
{
X = x;
Y = y;
}
public string ToBoardNotation()
{
var file = (char)(X + A);
var rank = Y + 1;
return $"{file}{rank}";
}
public static Coords FromBoardNotation(string notation)
{
if (string.IsNullOrEmpty(notation))
{
if (Regex.IsMatch(notation, BoardNotationRegex))
{
var match = Regex.Match(notation, BoardNotationRegex);
char file = match.Groups["file"].Value[0];
int rank = int.Parse(match.Groups["rank"].Value);
return new Coords(file - A, rank);
}
throw new ArgumentException("Board notation not recognized."); // TODO: Move this error handling to the service layer.
}
return new Coords(-1, -1); // Temporarily this is how I tell Gameboard.API that a piece came from the hand.
}
}
}

View File

@@ -1,41 +1,86 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
using System;
using System.Diagnostics;
using System.Numerics;
using System.Text.RegularExpressions;
namespace Gameboard.ShogiUI.Sockets.Models
{
[DebuggerDisplay("{From} - {To}")]
public class Move
{
public Coords? From { get; set; }
public bool IsPromotion { get; set; }
public WhichPiece? PieceFromHand { get; set; }
public Coords To { get; set; }
private static readonly string BoardNotationRegex = @"(?<file>[A-I])(?<rank>[1-9])";
private static readonly char A = 'A';
public Move(Coords from, Coords to, bool isPromotion)
public Vector2? From { get; }
public bool IsPromotion { get; }
public WhichPiece? PieceFromHand { get; }
public Vector2 To { get; }
public Move(Vector2 from, Vector2 to, bool isPromotion = false)
{
From = from;
To = to;
IsPromotion = isPromotion;
}
public Move(WhichPiece pieceFromHand, Coords to)
public Move(WhichPiece pieceFromHand, Vector2 to)
{
PieceFromHand = pieceFromHand;
To = to;
}
/// <summary>
/// Constructor to represent moving a piece on the Board to another position on the Board.
/// </summary>
/// <param name="fromNotation">Position the piece is being moved from.</param>
/// <param name="toNotation">Position the piece is being moved to.</param>
/// <param name="isPromotion">If the moving piece should be promoted.</param>
public Move(string fromNotation, string toNotation, bool isPromotion = false)
{
From = FromBoardNotation(fromNotation);
To = FromBoardNotation(toNotation);
IsPromotion = isPromotion;
}
/// <summary>
/// Constructor to represent moving a piece from the Hand to the Board.
/// </summary>
/// <param name="pieceFromHand">The piece being moved from the Hand to the Board.</param>
/// <param name="toNotation">Position the piece is being moved to.</param>
/// <param name="isPromotion">If the moving piece should be promoted.</param>
public Move(WhichPiece pieceFromHand, string toNotation, bool isPromotion = false)
{
From = null;
PieceFromHand = pieceFromHand;
To = FromBoardNotation(toNotation);
IsPromotion = isPromotion;
}
public ServiceModels.Socket.Types.Move ToServiceModel() => new()
{
From = From?.ToBoardNotation(),
From = From.HasValue ? ToBoardNotation(From.Value) : null,
IsPromotion = IsPromotion,
To = To.ToBoardNotation(),
PieceFromCaptured = PieceFromHand
PieceFromCaptured = PieceFromHand.HasValue ? PieceFromHand : null,
To = ToBoardNotation(To)
};
public Rules.Move ToRulesModel()
private static string ToBoardNotation(Vector2 vector)
{
return PieceFromHand != null
? new Rules.Move((Rules.WhichPiece)PieceFromHand, new Vector2(To.X, To.Y))
: new Rules.Move(new Vector2(From!.X, From.Y), new Vector2(To.X, To.Y), IsPromotion);
var file = (char)(vector.X + A);
var rank = vector.Y + 1;
return $"{file}{rank}";
}
private static Vector2 FromBoardNotation(string notation)
{
if (Regex.IsMatch(notation, BoardNotationRegex))
{
var match = Regex.Match(notation, BoardNotationRegex);
char file = match.Groups["file"].Value[0];
int rank = int.Parse(match.Groups["rank"].Value);
return new Vector2(file - A, rank);
}
throw new ArgumentException($"Board notation not recognized. Notation given: {notation}");
}
}
}

View File

@@ -0,0 +1,95 @@
using PathFinding;
using System.Collections.Generic;
namespace Gameboard.ShogiUI.Sockets.Models
{
public static class MoveSets
{
public static readonly List<PathFinding.Move> King = new(8)
{
new PathFinding.Move(Direction.Up),
new PathFinding.Move(Direction.Left),
new PathFinding.Move(Direction.Right),
new PathFinding.Move(Direction.Down),
new PathFinding.Move(Direction.UpLeft),
new PathFinding.Move(Direction.UpRight),
new PathFinding.Move(Direction.DownLeft),
new PathFinding.Move(Direction.DownRight)
};
public static readonly List<PathFinding.Move> Bishop = new(4)
{
new PathFinding.Move(Direction.UpLeft, Distance.MultiStep),
new PathFinding.Move(Direction.UpRight, Distance.MultiStep),
new PathFinding.Move(Direction.DownLeft, Distance.MultiStep),
new PathFinding.Move(Direction.DownRight, Distance.MultiStep)
};
public static readonly List<PathFinding.Move> PromotedBishop = new(8)
{
new PathFinding.Move(Direction.Up),
new PathFinding.Move(Direction.Left),
new PathFinding.Move(Direction.Right),
new PathFinding.Move(Direction.Down),
new PathFinding.Move(Direction.UpLeft, Distance.MultiStep),
new PathFinding.Move(Direction.UpRight, Distance.MultiStep),
new PathFinding.Move(Direction.DownLeft, Distance.MultiStep),
new PathFinding.Move(Direction.DownRight, Distance.MultiStep)
};
public static readonly List<PathFinding.Move> GoldGeneral = new(6)
{
new PathFinding.Move(Direction.Up),
new PathFinding.Move(Direction.UpLeft),
new PathFinding.Move(Direction.UpRight),
new PathFinding.Move(Direction.Left),
new PathFinding.Move(Direction.Right),
new PathFinding.Move(Direction.Down)
};
public static readonly List<PathFinding.Move> Knight = new(2)
{
new PathFinding.Move(Direction.KnightLeft),
new PathFinding.Move(Direction.KnightRight)
};
public static readonly List<PathFinding.Move> Lance = new(1)
{
new PathFinding.Move(Direction.Up, Distance.MultiStep),
};
public static readonly List<PathFinding.Move> Pawn = new(1)
{
new PathFinding.Move(Direction.Up)
};
public static readonly List<PathFinding.Move> Rook = new(4)
{
new PathFinding.Move(Direction.Up, Distance.MultiStep),
new PathFinding.Move(Direction.Left, Distance.MultiStep),
new PathFinding.Move(Direction.Right, Distance.MultiStep),
new PathFinding.Move(Direction.Down, Distance.MultiStep)
};
public static readonly List<PathFinding.Move> PromotedRook = new(8)
{
new PathFinding.Move(Direction.Up, Distance.MultiStep),
new PathFinding.Move(Direction.Left, Distance.MultiStep),
new PathFinding.Move(Direction.Right, Distance.MultiStep),
new PathFinding.Move(Direction.Down, Distance.MultiStep),
new PathFinding.Move(Direction.UpLeft),
new PathFinding.Move(Direction.UpRight),
new PathFinding.Move(Direction.DownLeft),
new PathFinding.Move(Direction.DownRight)
};
public static readonly List<PathFinding.Move> SilverGeneral = new(4)
{
new PathFinding.Move(Direction.Up),
new PathFinding.Move(Direction.UpLeft),
new PathFinding.Move(Direction.UpRight),
new PathFinding.Move(Direction.DownLeft),
new PathFinding.Move(Direction.DownRight)
};
}
}

View File

@@ -1,27 +1,59 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
using PathFinding;
using System.Diagnostics;
namespace Gameboard.ShogiUI.Sockets.Models
{
public class Piece
[DebuggerDisplay("{WhichPiece} {Owner}")]
public class Piece : IPlanarElement
{
public bool IsPromoted { get; }
public WhichPlayer Owner { get; }
public WhichPiece WhichPiece { get; }
public WhichPlayer Owner { get; private set; }
public bool IsPromoted { get; private set; }
public bool IsUpsideDown => Owner == WhichPlayer.Player2;
public Piece(bool isPromoted, WhichPlayer owner, WhichPiece whichPiece)
public Piece(WhichPiece piece, WhichPlayer owner, bool isPromoted = false)
{
IsPromoted = isPromoted;
WhichPiece = piece;
Owner = owner;
WhichPiece = whichPiece;
IsPromoted = isPromoted;
}
public Piece(Rules.Pieces.Piece piece)
public bool CanPromote => !IsPromoted
&& WhichPiece != WhichPiece.King
&& WhichPiece != WhichPiece.GoldGeneral;
public void ToggleOwnership()
{
IsPromoted = piece.IsPromoted;
Owner = (WhichPlayer)piece.Owner;
WhichPiece = (WhichPiece)piece.WhichPiece;
Owner = Owner == WhichPlayer.Player1
? WhichPlayer.Player2
: WhichPlayer.Player1;
}
public void Promote() => IsPromoted = CanPromote;
public void Demote() => IsPromoted = false;
public void Capture()
{
ToggleOwnership();
Demote();
}
// TODO: There is no reason to make "new" MoveSets every time this property is accessed.
public MoveSet MoveSet => WhichPiece switch
{
WhichPiece.King => new MoveSet(this, MoveSets.King),
WhichPiece.GoldGeneral => new MoveSet(this, MoveSets.GoldGeneral),
WhichPiece.SilverGeneral => new MoveSet(this, IsPromoted ? MoveSets.GoldGeneral : MoveSets.SilverGeneral),
WhichPiece.Bishop => new MoveSet(this, IsPromoted ? MoveSets.PromotedBishop : MoveSets.Bishop),
WhichPiece.Rook => new MoveSet(this, IsPromoted ? MoveSets.PromotedRook : MoveSets.Rook),
WhichPiece.Knight => new MoveSet(this, IsPromoted ? MoveSets.GoldGeneral : MoveSets.Knight),
WhichPiece.Lance => new MoveSet(this, IsPromoted ? MoveSets.GoldGeneral : MoveSets.Lance),
WhichPiece.Pawn => new MoveSet(this, IsPromoted ? MoveSets.GoldGeneral : MoveSets.Pawn),
_ => throw new System.NotImplementedException()
};
public ServiceModels.Socket.Types.Piece ToServiceModel()
{
return new ServiceModels.Socket.Types.Piece

View File

@@ -1,12 +0,0 @@
namespace Gameboard.ShogiUI.Sockets.Models
{
public class Player
{
public string Name { get; }
public Player(string name)
{
Name = name;
}
}
}

View File

@@ -1,22 +1,21 @@
using Gameboard.ShogiUI.Sockets.Extensions;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
using Newtonsoft.Json;
using Newtonsoft.Json;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.WebSockets;
using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.Models
{
public class Session
{
// TODO: Separate subscriptions to the Session from the Session.
[JsonIgnore] public ConcurrentDictionary<string, WebSocket> Subscriptions { get; }
public string Name { get; }
public string Player1 { get; }
public string? Player2 { get; }
public string? Player2 { get; private set; }
public bool IsPrivate { get; }
public Session(string name, bool isPrivate, string player1, string? player2 = null)
public Shogi Shogi { get; }
public Session(string name, bool isPrivate, Shogi shogi, string player1, string? player2 = null)
{
Subscriptions = new ConcurrentDictionary<string, WebSocket>();
@@ -24,48 +23,12 @@ namespace Gameboard.ShogiUI.Sockets.Models
Player1 = player1;
Player2 = player2;
IsPrivate = isPrivate;
Shogi = shogi;
}
public bool Subscribe(string playerName, WebSocket socket) => Subscriptions.TryAdd(playerName, socket);
public Task Broadcast(string message)
public void SetPlayer2(string userName)
{
var tasks = new List<Task>(Subscriptions.Count);
foreach (var kvp in Subscriptions)
{
var socket = kvp.Value;
tasks.Add(socket.SendTextAsync(message));
}
return Task.WhenAll(tasks);
}
public Task SendToPlayer1(string message)
{
if (Subscriptions.TryGetValue(Player1, out var socket))
{
return socket.SendTextAsync(message);
}
return Task.CompletedTask;
}
public Task SendToPlayer2(string message)
{
if (Player2 != null && Subscriptions.TryGetValue(Player2, out var socket))
{
return socket.SendTextAsync(message);
}
return Task.CompletedTask;
}
public Game ToServiceModel()
{
var players = new List<string>(2) { Player1 };
if (!string.IsNullOrWhiteSpace(Player2)) players.Add(Player2);
return new Game
{
GameName = Name,
Players = players.ToArray()
};
Player2 = userName;
}
}
}

View File

@@ -0,0 +1,30 @@
namespace Gameboard.ShogiUI.Sockets.Models
{
/// <summary>
/// A representation of a Session without the board and game-rules.
/// </summary>
public class SessionMetadata
{
public string Name { get; }
public string Player1 { get; }
public string? Player2 { get; }
public bool IsPrivate { get; }
public SessionMetadata(string name, bool isPrivate, string player1, string? player2)
{
Name = name;
IsPrivate = isPrivate;
Player1 = player1;
Player2 = player2;
}
public SessionMetadata(Session sessionModel)
{
Name = sessionModel.Name;
IsPrivate = sessionModel.IsPrivate;
Player1 = sessionModel.Player1;
Player2 = sessionModel.Player2;
}
public ServiceModels.Socket.Types.Game ToServiceModel() => new(Name, Player1, Player2);
}
}

View File

@@ -0,0 +1,410 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
using PathFinding;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
namespace Gameboard.ShogiUI.Sockets.Models
{
/// <summary>
/// Facilitates Shogi board state transitions, cognisant of Shogi rules.
/// The board is always from Player1's perspective.
/// [0,0] is the lower-left position, [8,8] is the higher-right position
/// </summary>
public class Shogi
{
private delegate void MoveSetCallback(Piece piece, Vector2 position);
private readonly PathFinder2D<Piece> pathFinder;
private Shogi? validationBoard;
private Vector2 player1King;
private Vector2 player2King;
public IReadOnlyDictionary<WhichPlayer, List<Piece>> Hands { get; }
public PlanarCollection<Piece> Board { get; } //TODO: Hide this being a getter method
public List<Move> MoveHistory { get; }
public WhichPlayer WhoseTurn => MoveHistory.Count % 2 == 0 ? WhichPlayer.Player1 : WhichPlayer.Player2;
public WhichPlayer? InCheck { get; private set; }
public bool IsCheckmate { get; private set; }
public string Error { get; private set; }
public Shogi()
{
Board = new PlanarCollection<Piece>(9, 9);
MoveHistory = new List<Move>(20);
Hands = new Dictionary<WhichPlayer, List<Piece>> {
{ WhichPlayer.Player1, new List<Piece>()},
{ WhichPlayer.Player2, new List<Piece>()},
};
pathFinder = new PathFinder2D<Piece>(Board);
player1King = new Vector2(4, 8);
player2King = new Vector2(4, 0);
Error = string.Empty;
InitializeBoardState();
}
public Shogi(IList<Move> moves) : this()
{
for (var i = 0; i < moves.Count; i++)
{
if (!Move(moves[i]))
{
// Todo: Add some smarts to know why a move was invalid. In check? Piece not found? etc.
throw new InvalidOperationException($"Unable to construct ShogiBoard with the given move at index {i}. {Error}");
}
}
}
private Shogi(Shogi toCopy)
{
Board = new PlanarCollection<Piece>(9, 9);
for (var x = 0; x < 9; x++)
for (var y = 0; y < 9; y++)
{
var piece = toCopy.Board[y, x];
if (piece != null)
{
Board[y, x] = new Piece(piece.WhichPiece, piece.Owner, piece.IsPromoted);
}
}
pathFinder = new PathFinder2D<Piece>(Board);
MoveHistory = new List<Move>(toCopy.MoveHistory);
Hands = new Dictionary<WhichPlayer, List<Piece>>
{
{ WhichPlayer.Player1, new List<Piece>(toCopy.Hands[WhichPlayer.Player1]) },
{ WhichPlayer.Player2, new List<Piece>(toCopy.Hands[WhichPlayer.Player2]) }
};
player1King = toCopy.player1King;
player2King = toCopy.player2King;
Error = toCopy.Error;
}
public bool Move(Move move)
{
var otherPlayer = WhoseTurn == WhichPlayer.Player1 ? WhichPlayer.Player2 : WhichPlayer.Player1;
var moveSuccess = TryMove(move);
if (!moveSuccess)
{
return false;
}
// Evaluate check
if (EvaluateCheckAfterMove(move, otherPlayer))
{
InCheck = otherPlayer;
IsCheckmate = EvaluateCheckmate();
}
return true;
}
/// <summary>
/// Attempts a given move. Returns false if the move is illegal.
/// </summary>
private bool TryMove(Move move)
{
// Try making the move in a "throw away" board.
if (validationBoard == null)
{
validationBoard = new Shogi(this);
}
var isValid = move.PieceFromHand.HasValue
? validationBoard.PlaceFromHand(move)
: validationBoard.PlaceFromBoard(move);
if (!isValid)
{
// Invalidate the "throw away" board.
validationBoard = null;
return false;
}
// If already in check, assert the move that resulted in check no longer results in check.
if (InCheck == WhoseTurn)
{
if (validationBoard.EvaluateCheckAfterMove(MoveHistory[^1], WhoseTurn))
{
// Sneakily using this.WhoseTurn instead of validationBoard.WhoseTurn;
return false;
}
}
// The move is valid and legal; update board state.
if (move.PieceFromHand.HasValue) PlaceFromHand(move);
else PlaceFromBoard(move);
return true;
}
/// <returns>True if the move was successful.</returns>
private bool PlaceFromHand(Move move)
{
if (move.PieceFromHand.HasValue == false) return false; //Invalid move
var index = Hands[WhoseTurn].FindIndex(p => p.WhichPiece == move.PieceFromHand);
if (index < 0) return false; // Invalid move
if (Board[move.To.Y, move.To.X] != null) return false; // Invalid move; cannot capture while playing from the hand.
var minimumY = 0;
switch (move.PieceFromHand.Value)
{
case WhichPiece.Knight:
// Knight cannot be placed onto the farthest two ranks from the hand.
minimumY = WhoseTurn == WhichPlayer.Player1 ? 6 : 2;
break;
case WhichPiece.Lance:
case WhichPiece.Pawn:
// Lance and Pawn cannot be placed onto the farthest rank from the hand.
minimumY = WhoseTurn == WhichPlayer.Player1 ? 7 : 1;
break;
}
if (WhoseTurn == WhichPlayer.Player1 && move.To.Y < minimumY) return false;
if (WhoseTurn == WhichPlayer.Player2 && move.To.Y > minimumY) return false;
// Mutate the board.
Board[move.To.Y, move.To.X] = Hands[WhoseTurn][index];
Hands[WhoseTurn].RemoveAt(index);
return true;
}
/// <returns>True if the move was successful.</returns>
private bool PlaceFromBoard(Move move)
{
var fromPiece = Board[move.From.Value.Y, move.From.Value.X];
if (fromPiece == null)
{
Error = $"No piece exists at {nameof(move)}.{nameof(move.From)}.";
return false; // Invalid move
}
if (fromPiece.Owner != WhoseTurn)
{
Error = "Not allowed to move the opponents piece";
return false; // Invalid move; cannot move other players pieces.
}
if (IsPathable(move.From.Value, move.To) == false)
{
Error = $"Illegal move for {fromPiece.WhichPiece}. {nameof(move)}.{nameof(move.To)} is not part of the move-set.";
return false; // Invalid move; move not part of move-set.
}
var captured = Board[move.To.Y, move.To.X];
if (captured != null)
{
if (captured.Owner == WhoseTurn) return false; // Invalid move; cannot capture your own piece.
captured.Capture();
Hands[captured.Owner].Add(captured);
}
//Mutate the board.
if (move.IsPromotion)
{
if (WhoseTurn == WhichPlayer.Player1 && (move.To.Y < 3 || move.From.Value.Y < 3))
{
fromPiece.Promote();
}
else if (WhoseTurn == WhichPlayer.Player2 && (move.To.Y > 5 || move.From.Value.Y > 5))
{
fromPiece.Promote();
}
}
Board[move.To.Y, move.To.X] = fromPiece;
Board[move.From.Value.Y, move.From.Value.X] = null;
if (fromPiece.WhichPiece == WhichPiece.King)
{
if (fromPiece.Owner == WhichPlayer.Player1)
{
player1King.X = move.To.X;
player1King.Y = move.To.Y;
}
else if (fromPiece.Owner == WhichPlayer.Player2)
{
player2King.X = move.To.X;
player2King.Y = move.To.Y;
}
}
MoveHistory.Add(move);
return true;
}
private bool IsPathable(Vector2 from, Vector2 to)
{
var piece = Board[from.Y, from.X];
if (piece == null) return false;
var isObstructed = false;
var isPathable = pathFinder.PathTo(from, to, (other, position) =>
{
if (other.Owner == piece.Owner) isObstructed = true;
});
return !isObstructed && isPathable;
}
#region Rules Validation
private bool EvaluateCheckAfterMove(Move move, WhichPlayer whichPlayer)
{
var isCheck = false;
var kingPosition = whichPlayer == WhichPlayer.Player1 ? player1King : player2King;
// Check if the move put the king in check.
if (pathFinder.PathTo(move.To, kingPosition)) return true;
// Get line equation from king through the now-unoccupied location.
var direction = Vector2.Subtract(kingPosition, move.From.Value);
var slope = Math.Abs(direction.Y / direction.X);
// If absolute slope is 45°, look for a bishop along the line.
// If absolute slope is 0° or 90°, look for a rook along the line.
// if absolute slope is 0°, look for lance along the line.
if (float.IsInfinity(slope))
{
// if slope of the move is also infinity...can skip this?
pathFinder.LinePathTo(kingPosition, direction, (piece, position) =>
{
if (piece.Owner != whichPlayer)
{
switch (piece.WhichPiece)
{
case WhichPiece.Rook:
isCheck = true;
break;
case WhichPiece.Lance:
if (!piece.IsPromoted) isCheck = true;
break;
}
}
});
}
else if (slope == 1)
{
pathFinder.LinePathTo(kingPosition, direction, (piece, position) =>
{
if (piece.Owner != whichPlayer && piece.WhichPiece == WhichPiece.Bishop)
{
isCheck = true;
}
});
}
else if (slope == 0)
{
pathFinder.LinePathTo(kingPosition, direction, (piece, position) =>
{
if (piece.Owner != whichPlayer && piece.WhichPiece == WhichPiece.Rook)
{
isCheck = true;
}
});
}
return isCheck;
}
private bool EvaluateCheckmate()
{
if (!InCheck.HasValue) return false;
// Assume true and try to disprove.
var isCheckmate = true;
Board.ForEachNotNull((piece, x, y) => // For each piece...
{
// Short circuit
if (!isCheckmate) return;
var from = new Vector2(x, y);
if (piece.Owner == InCheck) // ...owned by the player in check...
{
// ...evaluate if any move gets the player out of check.
pathFinder.PathEvery(from, (other, position) =>
{
if (validationBoard == null) validationBoard = new Shogi(this);
var moveToTry = new Move(from, position);
var moveSuccess = validationBoard.TryMove(moveToTry);
if (moveSuccess)
{
validationBoard = null;
if (!EvaluateCheckAfterMove(moveToTry, InCheck.Value))
{
isCheckmate = false;
}
}
});
}
});
return isCheckmate;
}
#endregion
#region Initialize
private void ResetEmptyRows()
{
for (int y = 3; y < 6; y++)
for (int x = 0; x < 9; x++)
Board[y, x] = null;
}
private void ResetFrontRow(WhichPlayer player)
{
int y = player == WhichPlayer.Player1 ? 6 : 2;
for (int x = 0; x < 9; x++) Board[y, x] = new Piece(WhichPiece.Pawn, player);
}
private void ResetMiddleRow(WhichPlayer player)
{
int y = player == WhichPlayer.Player1 ? 7 : 1;
Board[y, 0] = null;
for (int x = 2; x < 7; x++) Board[y, x] = null;
Board[y, 8] = null;
if (player == WhichPlayer.Player1)
{
Board[y, 1] = new Piece(WhichPiece.Bishop, player);
Board[y, 7] = new Piece(WhichPiece.Rook, player);
}
else
{
Board[y, 1] = new Piece(WhichPiece.Rook, player);
Board[y, 7] = new Piece(WhichPiece.Bishop, player);
}
}
private void ResetRearRow(WhichPlayer player)
{
int y = player == WhichPlayer.Player1 ? 8 : 0;
Board[y, 0] = new Piece(WhichPiece.Lance, player);
Board[y, 1] = new Piece(WhichPiece.Knight, player);
Board[y, 2] = new Piece(WhichPiece.SilverGeneral, player);
Board[y, 3] = new Piece(WhichPiece.GoldGeneral, player);
Board[y, 4] = new Piece(WhichPiece.King, player);
Board[y, 5] = new Piece(WhichPiece.GoldGeneral, player);
Board[y, 6] = new Piece(WhichPiece.SilverGeneral, player);
Board[y, 7] = new Piece(WhichPiece.Knight, player);
Board[y, 8] = new Piece(WhichPiece.Lance, player);
}
private void InitializeBoardState()
{
ResetRearRow(WhichPlayer.Player2);
ResetMiddleRow(WhichPlayer.Player2);
ResetFrontRow(WhichPlayer.Player2);
ResetEmptyRows();
ResetFrontRow(WhichPlayer.Player1);
ResetMiddleRow(WhichPlayer.Player1);
ResetRearRow(WhichPlayer.Player1);
}
#endregion
public BoardState ToServiceModel()
{
var board = new ServiceModels.Socket.Types.Piece[9, 9];
for (var x = 0; x < 9; x++)
for (var y = 0; y < 9; y++)
{
var piece = Board[y, x];
if (piece != null)
{
board[y, x] = piece.ToServiceModel();
}
}
return new BoardState
{
Board = board,
PlayerInCheck = InCheck,
WhoseTurn = WhoseTurn,
Player1Hand = Hands[WhichPlayer.Player1].Select(_ => _.ToServiceModel()).ToList(),
Player2Hand = Hands[WhichPlayer.Player2].Select(_ => _.ToServiceModel()).ToList()
};
}
}
}

View File

@@ -1,75 +0,0 @@
using System;
using System.Linq;
namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
{
public class BoardState : CouchDocument
{
public string Name { get; set; }
public Piece?[,] Board { get; set; }
public Piece[] Player1Hand { get; set; }
public Piece[] Player2Hand { get; set; }
/// <summary>
/// Move is null for first BoardState of a session - before anybody has made moves.
/// </summary>
public Move? Move { get; set; }
/// <summary>
/// Default constructor and setters are for deserialization.
/// </summary>
public BoardState() : base()
{
Name = string.Empty;
Board = new Piece[9, 9];
Player1Hand = Array.Empty<Piece>();
Player2Hand = Array.Empty<Piece>();
}
public BoardState(string sessionName, Models.BoardState boardState) : base($"{sessionName}-{DateTime.Now:O}", nameof(BoardState))
{
Name = sessionName;
Board = new Piece[9, 9];
for (var x = 0; x < 9; x++)
for (var y = 0; y < 9; y++)
{
var piece = boardState.Board[x, y];
if (piece != null)
{
Board[x, y] = new Piece(piece);
}
}
Player1Hand = boardState.Player1Hand.Select(model => new Piece(model)).ToArray();
Player2Hand = boardState.Player2Hand.Select(model => new Piece(model)).ToArray();
if (boardState.Move != null)
{
Move = new Move(boardState.Move);
}
}
public Models.BoardState ToDomainModel()
{
/*
* Board = new Piece[9, 9];
for (var x = 0; x < 9; x++)
for (var y = 0; y < 9; y++)
{
var piece = boardState.Board[x, y];
if (piece != null)
{
Board[x, y] = new Piece(piece);
}
}
Player1Hand = boardState.Player1Hand.Select(_ => new Piece(_)).ToList();
Player2Hand = boardState.Player2Hand.Select(_ => new Piece(_)).ToList();
if (boardState.Move != null)
{
Move = new Move(boardState.Move);
}
*/
return null;
}
}
}

View File

@@ -0,0 +1,57 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
using System;
using System.Linq;
namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
{
public class BoardStateDocument : CouchDocument
{
public string Name { get; set; }
public Piece?[,] Board { get; set; }
public Piece[] Player1Hand { get; set; }
public Piece[] Player2Hand { get; set; }
/// <summary>
/// Move is null for first BoardState of a session - before anybody has made moves.
/// </summary>
public Move? Move { get; set; }
/// <summary>
/// Default constructor and setters are for deserialization.
/// </summary>
public BoardStateDocument() : base(WhichDocumentType.BoardState)
{
Name = string.Empty;
Board = new Piece[9, 9];
Player1Hand = Array.Empty<Piece>();
Player2Hand = Array.Empty<Piece>();
}
public BoardStateDocument(string sessionName, Models.Shogi shogi)
: base($"{sessionName}-{DateTime.Now:O}", WhichDocumentType.BoardState)
{
Name = sessionName;
Board = new Piece[9, 9];
for (var x = 0; x < 9; x++)
for (var y = 0; y < 9; y++)
{
var piece = shogi.Board[y, x];
if (piece != null)
{
Board[y, x] = new Piece(piece);
}
}
Player1Hand = shogi.Hands[WhichPlayer.Player1].Select(model => new Piece(model)).ToArray();
Player2Hand = shogi.Hands[WhichPlayer.Player2].Select(model => new Piece(model)).ToArray();
if (shogi.MoveHistory.Count > 0)
{
Move = new Move(shogi.MoveHistory[^1]);
}
}
}
}

View File

@@ -5,21 +5,21 @@ namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
{
public abstract class CouchDocument
{
[JsonProperty("_id")]
public string Id { get; set; }
public string Type { get; set; }
[JsonProperty("_id")] public string Id { get; set; }
public WhichDocumentType DocumentType { get; }
public DateTimeOffset CreatedDate { get; set; }
public CouchDocument()
{
Id = string.Empty;
Type = string.Empty;
CreatedDate = DateTimeOffset.UtcNow;
}
public CouchDocument(string id, string type)
public CouchDocument(WhichDocumentType documentType)
: this(string.Empty, documentType, DateTimeOffset.UtcNow) { }
public CouchDocument(string id, WhichDocumentType documentType)
: this(id, documentType, DateTimeOffset.UtcNow) { }
public CouchDocument(string id, WhichDocumentType documentType, DateTimeOffset createdDate)
{
Id = id;
Type = type;
DocumentType = documentType;
CreatedDate = createdDate;
}
}
}

View File

@@ -1,5 +1,5 @@
using Gameboard.ShogiUI.Sockets.Models;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
using System.Numerics;
namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
{
@@ -32,14 +32,25 @@ namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
public Move(Models.Move move)
{
From = move.From?.ToBoardNotation();
if (move.From.HasValue)
{
From = ToBoardNotation(move.From.Value);
}
IsPromotion = move.IsPromotion;
To = move.To.ToBoardNotation();
To = ToBoardNotation(move.To);
PieceFromHand = move.PieceFromHand;
}
private static readonly char A = 'A';
private static string ToBoardNotation(Vector2 vector)
{
var file = (char)(vector.X + A);
var rank = vector.Y + 1;
return $"{file}{rank}";
}
public Models.Move ToDomainModel() => PieceFromHand.HasValue
? new((ServiceModels.Socket.Types.WhichPiece)PieceFromHand, Coords.FromBoardNotation(To))
: new(Coords.FromBoardNotation(From!), Coords.FromBoardNotation(To), IsPromotion);
? new(PieceFromHand.Value, To)
: new(From!, To, IsPromotion);
}
}

View File

@@ -22,6 +22,6 @@ namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
WhichPiece = piece.WhichPiece;
}
public Models.Piece ToDomainModel() => new(IsPromoted, Owner, WhichPiece);
public Models.Piece ToDomainModel() => new(WhichPiece, Owner, IsPromoted);
}
}

View File

@@ -1,4 +0,0 @@
### Couch Models
Couch models should accept domain models during construction and offer a ToDomainModel method which constructs a domain model.
In this way, domain models have the freedom to define their valid states.

View File

@@ -1,30 +0,0 @@
namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
{
public class Session : CouchDocument
{
public string Name { get; set; }
public string Player1 { get; set; }
public string? Player2 { get; set; }
public bool IsPrivate { get; set; }
/// <summary>
/// Default constructor and setters are for deserialization.
/// </summary>
public Session() : base()
{
Name = string.Empty;
Player1 = string.Empty;
Player2 = string.Empty;
}
public Session(string id, Models.Session session) : base(id, nameof(Session))
{
Name = session.Name;
Player1 = session.Player1;
Player2 = session.Player2;
IsPrivate = session.IsPrivate;
}
public Models.Session ToDomainModel() => new(Name, IsPrivate, Player1, Player2);
}
}

View File

@@ -0,0 +1,48 @@
using System.Collections.Generic;
namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
{
public class SessionDocument : CouchDocument
{
public string Name { get; set; }
public string Player1 { get; set; }
public string? Player2 { get; set; }
public bool IsPrivate { get; set; }
public IList<BoardStateDocument> History { get; set; }
/// <summary>
/// Default constructor and setters are for deserialization.
/// </summary>
public SessionDocument() : base(WhichDocumentType.Session)
{
Name = string.Empty;
Player1 = string.Empty;
Player2 = string.Empty;
History = new List<BoardStateDocument>(0);
}
public SessionDocument(Models.Session session)
: base(session.Name, WhichDocumentType.Session)
{
Name = session.Name;
Player1 = session.Player1;
Player2 = session.Player2;
IsPrivate = session.IsPrivate;
History = new List<BoardStateDocument>(0);
}
public SessionDocument(Models.SessionMetadata sessionMetaData)
: base(sessionMetaData.Name, WhichDocumentType.Session)
{
Name = sessionMetaData.Name;
Player1 = sessionMetaData.Player1;
Player2 = sessionMetaData.Player2;
IsPrivate = sessionMetaData.IsPrivate;
History = new List<BoardStateDocument>(0);
}
public Models.Session ToDomainModel(Models.Shogi shogi) => new(Name, IsPrivate, shogi, Player1, Player2);
public Models.SessionMetadata ToDomainModel() => new(Name, IsPrivate, Player1, Player2);
}
}

View File

@@ -1,8 +1,6 @@
using System;
namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
{
public class User : CouchDocument
public class UserDocument : CouchDocument
{
public static string GetDocumentId(string userName) => $"org.couchdb.user:{userName}";
@@ -14,7 +12,7 @@ namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
public string Name { get; set; }
public LoginPlatform Platform { get; set; }
public User(string name, LoginPlatform platform) : base($"org.couchdb.user:{name}", nameof(User))
public UserDocument(string name, LoginPlatform platform) : base($"org.couchdb.user:{name}", WhichDocumentType.User)
{
Name = name;
Platform = platform;

View File

@@ -0,0 +1,9 @@
namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
{
public enum WhichDocumentType
{
User,
Session,
BoardState
}
}

View File

@@ -12,14 +12,14 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
{
public interface IGameboardRepository
{
Task<bool> CreateBoardState(string sessionName, Models.BoardState boardState, Models.Move? move);
Task<bool> CreateGuestUser(string userName);
Task<bool> CreateSession(Models.Session session);
Task<IList<Models.Session>> ReadSessions();
Task<bool> CreateSession(Models.SessionMetadata session);
Task<IList<Models.SessionMetadata>> ReadSessionMetadatas();
Task<bool> IsGuestUser(string userName);
Task<string> PostJoinCode(string gameName, string userName);
Task<Models.Session?> ReadSession(string name);
Task<IList<Models.BoardState>> ReadBoardStates(string name);
Task<Models.Shogi?> ReadShogi(string name);
Task<bool> UpdateSession(Models.Session session);
}
public class GameboardRepository : IGameboardRepository
@@ -34,75 +34,99 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
this.logger = logger;
}
public async Task<IList<Models.Session>> ReadSessions()
public async Task<IList<Models.SessionMetadata>> ReadSessionMetadatas()
{
var selector = $@"{{ ""{nameof(Session.Type)}"": ""{nameof(Session)}"" }}";
var query = $@"{{ ""selector"": {selector} }}";
var content = new StringContent(query, Encoding.UTF8, ApplicationJson);
var selector = new Dictionary<string, object>(2)
{
[nameof(SessionDocument.DocumentType)] = WhichDocumentType.Session
};
var q = new { Selector = selector };
var content = new StringContent(JsonConvert.SerializeObject(q), Encoding.UTF8, ApplicationJson);
var response = await client.PostAsync("_find", content);
var responseContent = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<CouchFindResult<Session>>(responseContent);
var sessions = JsonConvert.DeserializeObject<CouchFindResult<SessionDocument>>(responseContent).docs;
if (result == null)
{
logger.LogError("Unable to deserialize couchdb result during {0}.", nameof(this.ReadSessions));
return Array.Empty<Models.Session>();
}
return result.docs
.Select(_ => _.ToDomainModel())
return sessions
.Select(s => new Models.SessionMetadata(s.Name, s.IsPrivate, s.Player1, s.Player2))
.ToList();
}
public async Task<Models.Session?> ReadSession(string name)
{
var readShogiTask = ReadShogi(name);
var response = await client.GetAsync(name);
var responseContent = await response.Content.ReadAsStringAsync();
var couchModel = JsonConvert.DeserializeObject<Session>(responseContent);
return couchModel.ToDomainModel();
var couchModel = JsonConvert.DeserializeObject<SessionDocument>(responseContent);
var shogi = await readShogiTask;
if (shogi == null)
{
return null;
}
return couchModel.ToDomainModel(shogi);
}
public async Task<IList<Models.BoardState>> ReadBoardStates(string name)
public async Task<Models.Shogi?> ReadShogi(string name)
{
var selector = $@"{{ ""{nameof(BoardState.Type)}"": ""{nameof(BoardState)}"", ""{nameof(BoardState.Name)}"": ""{name}"" }}";
var sort = $@"{{ ""{nameof(BoardState.CreatedDate)}"" : ""desc"" }}";
var query = $@"{{ ""selector"": {selector}, ""sort"": {sort} }}";
var selector = new Dictionary<string, object>(2)
{
[nameof(BoardStateDocument.DocumentType)] = WhichDocumentType.BoardState,
[nameof(BoardStateDocument.Name)] = name
};
var sort = new Dictionary<string, object>(1)
{
[nameof(BoardStateDocument.CreatedDate)] = "desc"
};
var query = JsonConvert.SerializeObject(new { selector, sort = new[] { sort } });
var content = new StringContent(query, Encoding.UTF8, ApplicationJson);
var response = await client.PostAsync("_find", content);
var responseContent = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<CouchFindResult<BoardState>>(responseContent);
if (result == null)
if (!response.IsSuccessStatusCode)
{
logger.LogError("Unable to deserialize couchdb result during {0}.", nameof(this.ReadSessions));
return Array.Empty<Models.BoardState>();
logger.LogError("Couch error during _find in {func}: {error}.\n\nQuery: {query}", nameof(ReadShogi), responseContent, query);
return null;
}
return result.docs
.Select(_ => new Models.BoardState(_))
.ToList();
var boardStates = JsonConvert
.DeserializeObject<CouchFindResult<BoardStateDocument>>(responseContent)
.docs;
if (boardStates.Length == 0) return null;
// Skip(1) because the first BoardState has no move; it represents the initial board state of a new Session.
var moves = boardStates.Skip(1).Select(couchModel =>
{
var move = couchModel.Move;
Models.Move model = move!.PieceFromHand.HasValue
? new Models.Move(move.PieceFromHand.Value, move.To)
: new Models.Move(move.From!, move.To, move.IsPromotion);
return model;
}).ToList();
return new Models.Shogi(moves);
}
//public async Task DeleteGame(string gameName)
//{
// //var uri = $"Session/{gameName}";
// //await client.DeleteAsync(Uri.EscapeUriString(uri));
//}
public async Task<bool> CreateSession(Models.Session session)
public async Task<bool> CreateSession(Models.SessionMetadata session)
{
var couchModel = new Session(session.Name, session);
var sessionDocument = new SessionDocument(session);
var sessionContent = new StringContent(JsonConvert.SerializeObject(sessionDocument), Encoding.UTF8, ApplicationJson);
var postSessionDocumentTask = client.PostAsync(string.Empty, sessionContent);
var boardStateDocument = new BoardStateDocument(session.Name, new Models.Shogi());
var boardStateContent = new StringContent(JsonConvert.SerializeObject(boardStateDocument), Encoding.UTF8, ApplicationJson);
if ((await postSessionDocumentTask).IsSuccessStatusCode)
{
var response = await client.PostAsync(string.Empty, boardStateContent);
return response.IsSuccessStatusCode;
}
return false;
}
public async Task<bool> UpdateSession(Models.Session session)
{
var couchModel = new SessionDocument(session);
var content = new StringContent(JsonConvert.SerializeObject(couchModel), Encoding.UTF8, ApplicationJson);
var response = await client.PostAsync(string.Empty, content);
var response = await client.PutAsync(couchModel.Id, content);
return response.IsSuccessStatusCode;
}
public async Task<bool> CreateBoardState(string sessionName, Models.BoardState boardState, Models.Move? move)
{
var couchModel = new BoardState(sessionName, boardState, move);
var content = new StringContent(JsonConvert.SerializeObject(couchModel), Encoding.UTF8, ApplicationJson);
var response = await client.PostAsync(string.Empty, content);
return response.IsSuccessStatusCode;
}
//public async Task<bool> PutJoinPublicSession(PutJoinPublicSession request)
//{
// var content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, MediaType);
@@ -169,7 +193,7 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
public async Task<bool> CreateGuestUser(string userName)
{
var couchModel = new User(userName, User.LoginPlatform.Guest);
var couchModel = new UserDocument(userName, UserDocument.LoginPlatform.Guest);
var content = new StringContent(JsonConvert.SerializeObject(couchModel), Encoding.UTF8, ApplicationJson);
var response = await client.PostAsync(string.Empty, content);
return response.IsSuccessStatusCode;
@@ -177,7 +201,7 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
public async Task<bool> IsGuestUser(string userName)
{
var req = new HttpRequestMessage(HttpMethod.Head, new Uri($"{client.BaseAddress}/{User.GetDocumentId(userName)}"));
var req = new HttpRequestMessage(HttpMethod.Head, new Uri($"{client.BaseAddress}/{UserDocument.GetDocumentId(userName)}"));
var response = await client.SendAsync(req);
return response.IsSuccessStatusCode;
}

View File

@@ -0,0 +1,15 @@
using FluentValidation;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
namespace Gameboard.ShogiUI.Sockets.Services.RequestValidators
{
public class CreateGameRequestValidator : AbstractValidator<CreateGameRequest>
{
public CreateGameRequestValidator()
{
RuleFor(_ => _.Action).Equal(ClientAction.CreateGame);
RuleFor(_ => _.GameName).NotEmpty();
}
}
}

View File

@@ -0,0 +1,15 @@
using FluentValidation;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
namespace Gameboard.ShogiUI.Sockets.Services.RequestValidators
{
public class JoinByCodeRequestValidator : AbstractValidator<JoinByCodeRequest>
{
public JoinByCodeRequestValidator()
{
RuleFor(_ => _.Action).Equal(ClientAction.JoinByCode);
RuleFor(_ => _.JoinCode).NotEmpty();
}
}
}

View File

@@ -0,0 +1,15 @@
using FluentValidation;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
namespace Gameboard.ShogiUI.Sockets.Services.RequestValidators
{
public class JoinGameRequestValidator : AbstractValidator<JoinGameRequest>
{
public JoinGameRequestValidator()
{
RuleFor(_ => _.Action).Equal(ClientAction.JoinGame);
RuleFor(_ => _.GameName).NotEmpty();
}
}
}

View File

@@ -0,0 +1,14 @@
using FluentValidation;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
namespace Gameboard.ShogiUI.Sockets.Services.RequestValidators
{
public class ListGamesRequestValidator : AbstractValidator<ListGamesRequest>
{
public ListGamesRequestValidator()
{
RuleFor(_ => _.Action).Equal(ClientAction.ListGames);
}
}
}

View File

@@ -0,0 +1,15 @@
using FluentValidation;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
namespace Gameboard.ShogiUI.Sockets.Services.RequestValidators
{
public class LoadGameRequestValidator : AbstractValidator<LoadGameRequest>
{
public LoadGameRequestValidator()
{
RuleFor(_ => _.Action).Equal(ClientAction.LoadGame);
RuleFor(_ => _.GameName).NotEmpty();
}
}
}

View File

@@ -0,0 +1,23 @@
using FluentValidation;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
namespace Gameboard.ShogiUI.Sockets.Services.RequestValidators
{
public class MoveRequestValidator : AbstractValidator<MoveRequest>
{
public MoveRequestValidator()
{
RuleFor(_ => _.Action).Equal(ClientAction.Move);
RuleFor(_ => _.GameName).NotEmpty();
RuleFor(_ => _.Move.From)
.Null()
.When(_ => _.Move.PieceFromCaptured.HasValue)
.WithMessage("Move.From and Move.PieceFromCaptured are mutually exclusive properties.");
RuleFor(_ => _.Move.From)
.NotEmpty()
.When(_ => !_.Move.PieceFromCaptured.HasValue)
.WithMessage("Move.From and Move.PieceFromCaptured are mutually exclusive properties.");
}
}
}

View File

@@ -0,0 +1,194 @@
using FluentValidation;
using Gameboard.ShogiUI.Sockets.Extensions;
using Gameboard.ShogiUI.Sockets.Managers;
using Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers;
using Gameboard.ShogiUI.Sockets.Managers.Utility;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Interfaces;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System;
using System.Linq;
using System.Net;
using System.Net.WebSockets;
using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.Services
{
public interface ISocketService
{
Task HandleSocketRequest(HttpContext context);
}
/// <summary>
/// Services a single websocket connection. Authenticates the socket connection, accepts messages, and sends messages.
/// </summary>
public class SocketService : ISocketService
{
private readonly ILogger<SocketService> logger;
private readonly ISocketConnectionManager communicationManager;
private readonly ISocketTokenManager tokenManager;
private readonly ICreateGameHandler createGameHandler;
private readonly IJoinByCodeHandler joinByCodeHandler;
private readonly IJoinGameHandler joinGameHandler;
private readonly IListGamesHandler listGamesHandler;
private readonly ILoadGameHandler loadGameHandler;
private readonly IMoveHandler moveHandler;
private readonly IValidator<CreateGameRequest> createGameRequestValidator;
private readonly IValidator<JoinByCodeRequest> joinByCodeRequestValidator;
private readonly IValidator<JoinGameRequest> joinGameRequestValidator;
private readonly IValidator<ListGamesRequest> listGamesRequestValidator;
private readonly IValidator<LoadGameRequest> loadGameRequestValidator;
private readonly IValidator<MoveRequest> moveRequestValidator;
public SocketService(
ILogger<SocketService> logger,
ISocketConnectionManager communicationManager,
ISocketTokenManager tokenManager,
ICreateGameHandler createGameHandler,
IJoinByCodeHandler joinByCodeHandler,
IJoinGameHandler joinGameHandler,
IListGamesHandler listGamesHandler,
ILoadGameHandler loadGameHandler,
IMoveHandler moveHandler,
IValidator<CreateGameRequest> createGameRequestValidator,
IValidator<JoinByCodeRequest> joinByCodeRequestValidator,
IValidator<JoinGameRequest> joinGameRequestValidator,
IValidator<ListGamesRequest> listGamesRequestValidator,
IValidator<LoadGameRequest> loadGameRequestValidator,
IValidator<MoveRequest> moveRequestValidator
) : base()
{
this.logger = logger;
this.communicationManager = communicationManager;
this.tokenManager = tokenManager;
this.createGameHandler = createGameHandler;
this.joinByCodeHandler = joinByCodeHandler;
this.joinGameHandler = joinGameHandler;
this.listGamesHandler = listGamesHandler;
this.loadGameHandler = loadGameHandler;
this.moveHandler = moveHandler;
this.createGameRequestValidator = createGameRequestValidator;
this.joinByCodeRequestValidator = joinByCodeRequestValidator;
this.joinGameRequestValidator = joinGameRequestValidator;
this.listGamesRequestValidator = listGamesRequestValidator;
this.loadGameRequestValidator = loadGameRequestValidator;
this.moveRequestValidator = moveRequestValidator;
}
public async Task HandleSocketRequest(HttpContext context)
{
var hasToken = context.Request.Query.Keys.Contains("token");
if (hasToken)
{
var oneTimeToken = context.Request.Query["token"][0];
var tokenAsGuid = Guid.Parse(oneTimeToken);
var userName = tokenManager.GetUsername(tokenAsGuid);
if (userName != null)
{
var socket = await context.WebSockets.AcceptWebSocketAsync();
communicationManager.SubscribeToBroadcast(socket, userName);
while (socket.State == WebSocketState.Open)
{
try
{
var message = await socket.ReceiveTextAsync();
if (string.IsNullOrWhiteSpace(message)) continue;
logger.LogInformation("Request \n{0}\n", message);
var request = JsonConvert.DeserializeObject<Request>(message);
if (!Enum.IsDefined(typeof(ClientAction), request.Action))
{
await socket.SendTextAsync("Error: Action not recognized.");
continue;
}
switch (request.Action)
{
case ClientAction.ListGames:
{
var req = JsonConvert.DeserializeObject<ListGamesRequest>(message);
if (await ValidateRequestAndReplyIfInvalid(socket, listGamesRequestValidator, req))
{
await listGamesHandler.Handle(req, userName);
}
break;
}
case ClientAction.CreateGame:
{
var req = JsonConvert.DeserializeObject<CreateGameRequest>(message);
if (await ValidateRequestAndReplyIfInvalid(socket, createGameRequestValidator, req))
{
await createGameHandler.Handle(req, userName);
}
break;
}
case ClientAction.JoinGame:
{
var req = JsonConvert.DeserializeObject<JoinGameRequest>(message);
if (await ValidateRequestAndReplyIfInvalid(socket, joinGameRequestValidator, req))
{
await joinGameHandler.Handle(req, userName);
}
break;
}
case ClientAction.JoinByCode:
{
var req = JsonConvert.DeserializeObject<JoinByCodeRequest>(message);
if (await ValidateRequestAndReplyIfInvalid(socket, joinByCodeRequestValidator, req))
{
await joinByCodeHandler.Handle(req, userName);
}
break;
}
case ClientAction.LoadGame:
{
var req = JsonConvert.DeserializeObject<LoadGameRequest>(message);
if (await ValidateRequestAndReplyIfInvalid(socket, loadGameRequestValidator, req))
{
await loadGameHandler.Handle(req, userName);
}
break;
}
case ClientAction.Move:
{
var req = JsonConvert.DeserializeObject<MoveRequest>(message);
if (await ValidateRequestAndReplyIfInvalid(socket, moveRequestValidator, req))
{
await moveHandler.Handle(req, userName);
}
break;
}
}
}
catch (OperationCanceledException ex)
{
logger.LogError(ex.Message);
}
catch (WebSocketException ex)
{
logger.LogInformation($"{nameof(WebSocketException)} in {nameof(SocketConnectionManager)}.");
logger.LogInformation("Probably tried writing to a closed socket.");
logger.LogError(ex.Message);
}
}
communicationManager.UnsubscribeFromBroadcastAndGames(userName);
return;
}
}
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
return;
}
public async Task<bool> ValidateRequestAndReplyIfInvalid<TRequest>(WebSocket socket, IValidator<TRequest> validator, TRequest request)
{
var results = validator.Validate(request);
if (!results.IsValid)
{
await socket.SendTextAsync(string.Join('\n', results.Errors.Select(_ => _.ErrorMessage).ToString()));
}
return results.IsValid;
}
}
}

View File

@@ -1,8 +1,11 @@
using FluentValidation;
using Gameboard.ShogiUI.Sockets.Extensions;
using Gameboard.ShogiUI.Sockets.Managers;
using Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers;
using Gameboard.ShogiUI.Sockets.Repositories;
using Gameboard.ShogiUI.Sockets.Repositories.RepositoryManagers;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages;
using Gameboard.ShogiUI.Sockets.Services;
using Gameboard.ShogiUI.Sockets.Services.RequestValidators;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@@ -39,11 +42,19 @@ namespace Gameboard.ShogiUI.Sockets
services.AddSingleton<IMoveHandler, MoveHandler>();
// Managers
services.AddSingleton<ISocketCommunicationManager, SocketCommunicationManager>();
services.AddSingleton<ISocketTokenManager, SocketTokenManager>();
services.AddSingleton<ISocketConnectionManager, SocketConnectionManager>();
services.AddSingleton<IGameboardRepositoryManager, GameboardRepositoryManager>();
services.AddSingleton<IBoardManager, BoardManager>();
services.AddSingleton<ISocketTokenManager, SocketTokenManager>();
services.AddSingleton<IGameboardManager, GameboardManager>();
services.AddSingleton<IActiveSessionManager, ActiveSessionManager>();
// Services
services.AddSingleton<IValidator<CreateGameRequest>, CreateGameRequestValidator>();
services.AddSingleton<IValidator<JoinByCodeRequest>, JoinByCodeRequestValidator>();
services.AddSingleton<IValidator<JoinGameRequest>, JoinGameRequestValidator>();
services.AddSingleton<IValidator<ListGamesRequest>, ListGamesRequestValidator>();
services.AddSingleton<IValidator<LoadGameRequest>, LoadGameRequestValidator>();
services.AddSingleton<IValidator<MoveRequest>, MoveRequestValidator>();
services.AddSingleton<ISocketService, SocketService>();
// Repositories
services.AddHttpClient("couchdb", c =>
@@ -77,7 +88,7 @@ namespace Gameboard.ShogiUI.Sockets
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ISocketConnectionManager socketConnectionManager)
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ISocketService socketConnectionManager)
{
var origins = new[] {
"http://localhost:3000", "https://localhost:3000",
@@ -135,10 +146,13 @@ namespace Gameboard.ShogiUI.Sockets
Formatting = Formatting.Indented,
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new CamelCaseNamingStrategy(),
NamingStrategy = new CamelCaseNamingStrategy
{
ProcessDictionaryKeys = true
}
},
Converters = new[] { new StringEnumConverter() },
NullValueHandling = NullValueHandling.Ignore
NullValueHandling = NullValueHandling.Ignore,
};
}
}