From 11b387b9280886e61753ac74a50fbd94f07019ee Mon Sep 17 00:00:00 2001 From: Lucas Morgan Date: Mon, 23 Jan 2023 17:25:41 -0600 Subject: [PATCH] Working on "Join Game" feature. --- Shogi.Api/Controllers/SessionsController.cs | 255 +++++++++--------- Shogi.Api/Program.cs | 1 - Shogi.Api/Repositories/QueryRepository.cs | 3 +- Shogi.Api/Repositories/SessionRepository.cs | 168 ++++++------ .../Session/Stored Procedures/SetPlayer2.sql | 16 ++ .../Stored Procedures/UpdateSession.sql | 14 - Shogi.Database/Shogi.Database.sqlproj | 2 +- Shogi.UI/Pages/Home/Api/IShogiApi.cs | 3 +- Shogi.UI/Pages/Home/Api/ShogiApi.cs | 19 +- Shogi.UI/Pages/Home/GameBoard/GameBoard.razor | 11 +- .../GameBoard/GameBoardPresentation.razor | 66 +++-- .../GameBoard/GameboardPresentation.razor.css | 18 +- .../Home/GameBoard/SeatedGameBoard.razor | 31 ++- Shogi.UI/Program.cs | 3 - Shogi.UI/Shared/ShogiSocket.cs | 141 +++++----- Shogi.UI/wwwroot/css/app.css | 4 + Tests/AcceptanceTests/AcceptanceTests.cs | 66 ++++- .../TestSetup/GuestTestFixture.cs | 32 ++- 18 files changed, 509 insertions(+), 344 deletions(-) create mode 100644 Shogi.Database/Session/Stored Procedures/SetPlayer2.sql delete mode 100644 Shogi.Database/Session/Stored Procedures/UpdateSession.sql diff --git a/Shogi.Api/Controllers/SessionsController.cs b/Shogi.Api/Controllers/SessionsController.cs index 47de079..ac6b453 100644 --- a/Shogi.Api/Controllers/SessionsController.cs +++ b/Shogi.Api/Controllers/SessionsController.cs @@ -15,141 +15,144 @@ namespace Shogi.Api.Controllers; [Authorize] public class SessionsController : ControllerBase { - private readonly ISocketConnectionManager communicationManager; - private readonly IModelMapper mapper; - private readonly ISessionRepository sessionRepository; - private readonly IQueryRespository queryRespository; - private readonly ILogger logger; + private readonly ISocketConnectionManager communicationManager; + private readonly IModelMapper mapper; + private readonly ISessionRepository sessionRepository; + private readonly IQueryRespository queryRespository; + private readonly ILogger logger; - public SessionsController( - ISocketConnectionManager communicationManager, - IModelMapper mapper, - ISessionRepository sessionRepository, - IQueryRespository queryRespository, - ILogger logger) - { - this.communicationManager = communicationManager; - this.mapper = mapper; - this.sessionRepository = sessionRepository; - this.queryRespository = queryRespository; - this.logger = logger; - } - - [HttpPost] - public async Task CreateSession([FromBody] CreateSessionCommand request) - { - var userId = User.GetShogiUserId(); - var session = new Domain.Session(request.Name, userId); - try + public SessionsController( + ISocketConnectionManager communicationManager, + IModelMapper mapper, + ISessionRepository sessionRepository, + IQueryRespository queryRespository, + ILogger logger) { - await sessionRepository.CreateSession(session); - } - catch (SqlException e) - { - logger.LogError(exception: e, message: "Uh oh"); - return this.Conflict(); + this.communicationManager = communicationManager; + this.mapper = mapper; + this.sessionRepository = sessionRepository; + this.queryRespository = queryRespository; + this.logger = logger; } - await communicationManager.BroadcastToAll(new SessionCreatedSocketMessage()); - return CreatedAtAction(nameof(CreateSession), new { sessionName = request.Name }, null); - } - - [HttpDelete("{name}")] - public async Task DeleteSession(string name) - { - var userId = User.GetShogiUserId(); - var session = await sessionRepository.ReadSession(name); - - if (session == null) return this.NoContent(); - - if (session.Player1 == userId) + [HttpPost] + public async Task CreateSession([FromBody] CreateSessionCommand request) { - await sessionRepository.DeleteSession(name); - return this.NoContent(); - } - - return this.StatusCode(StatusCodes.Status403Forbidden, "Cannot delete sessions created by others."); - } - - [HttpGet("PlayerCount")] - public async Task> GetSessionsPlayerCount() - { - return Ok(await this.queryRespository.ReadSessionPlayerCount(this.User.GetShogiUserId())); - } - - [HttpGet("{name}")] - public async Task> GetSession(string name) - { - var session = await sessionRepository.ReadSession(name); - if (session == null) return this.NotFound(); - - return new ReadSessionResponse - { - Session = new Session - { - BoardState = new BoardState + var userId = User.GetShogiUserId(); + var session = new Domain.Session(request.Name, userId); + try { - Board = session.Board.BoardState.State.ToContract(), - Player1Hand = session.Board.BoardState.Player1Hand.ToContract(), - Player2Hand = session.Board.BoardState.Player2Hand.ToContract(), - PlayerInCheck = session.Board.BoardState.InCheck?.ToContract(), - WhoseTurn = session.Board.BoardState.WhoseTurn.ToContract() - }, - Player1 = session.Player1, - Player2 = session.Player2, - SessionName = session.Name - } - }; - } + await sessionRepository.CreateSession(session); + } + catch (SqlException e) + { + logger.LogError(exception: e, message: "Uh oh"); + return this.Conflict(); + } - [HttpPut("{name}/Join")] - public async Task JoinSession(string name) - { - var session = await sessionRepository.ReadSession(name); - if (session == null) return this.NotFound(); - - if (string.IsNullOrEmpty(session.Player2)) - { - session.AddPlayer2(User.GetShogiUserId()); + await communicationManager.BroadcastToAll(new SessionCreatedSocketMessage()); + return CreatedAtAction(nameof(CreateSession), new { sessionName = request.Name }, null); } - return this.Ok(); - } - [HttpPatch("{sessionName}/Move")] - public async Task Move([FromRoute] string sessionName, [FromBody] MovePieceCommand command) - { - var userId = User.GetShogiUserId(); - var session = await sessionRepository.ReadSession(sessionName); - - if (session == null) return this.NotFound("Shogi session does not exist."); - - if (!session.IsSeated(userId)) return this.StatusCode(StatusCodes.Status403Forbidden, "Player is not a member of the Shogi session."); - - try + [HttpDelete("{name}")] + public async Task DeleteSession(string name) { - if (command.PieceFromHand.HasValue) - { - session.Board.Move(command.PieceFromHand.Value.ToDomain(), command.To); - } - else - { - session.Board.Move(command.From!, command.To, command.IsPromotion ?? false); - } - } - catch (InvalidOperationException e) - { - return this.Conflict(e.Message); - } - await sessionRepository.CreateMove(sessionName, command); - await communicationManager.BroadcastToPlayers( - new PlayerHasMovedMessage - { - PlayerName = userId, - SessionName = session.Name, - }, - session.Player1, - session.Player2); + var userId = User.GetShogiUserId(); + var session = await sessionRepository.ReadSession(name); - return this.NoContent(); - } + if (session == null) return this.NoContent(); + + if (session.Player1 == userId) + { + await sessionRepository.DeleteSession(name); + return this.NoContent(); + } + + return this.StatusCode(StatusCodes.Status403Forbidden, "Cannot delete sessions created by others."); + } + + [HttpGet("PlayerCount")] + public async Task> GetSessionsPlayerCount() + { + return Ok(await this.queryRespository.ReadSessionPlayerCount(this.User.GetShogiUserId())); + } + + [HttpGet("{name}")] + public async Task> GetSession(string name) + { + var session = await sessionRepository.ReadSession(name); + if (session == null) return this.NotFound(); + + return new ReadSessionResponse + { + Session = new Session + { + BoardState = new BoardState + { + Board = session.Board.BoardState.State.ToContract(), + Player1Hand = session.Board.BoardState.Player1Hand.ToContract(), + Player2Hand = session.Board.BoardState.Player2Hand.ToContract(), + PlayerInCheck = session.Board.BoardState.InCheck?.ToContract(), + WhoseTurn = session.Board.BoardState.WhoseTurn.ToContract() + }, + Player1 = session.Player1, + Player2 = session.Player2, + SessionName = session.Name + } + }; + } + + [HttpPatch("{name}/Join")] + public async Task JoinSession(string name) + { + var session = await sessionRepository.ReadSession(name); + if (session == null) return this.NotFound(); + + if (string.IsNullOrEmpty(session.Player2)) + { + session.AddPlayer2(User.GetShogiUserId()); + + await sessionRepository.SetPlayer2(name, User.GetShogiUserId()); + return this.Ok(); + } + return this.Conflict("This game already has two players."); + } + + [HttpPatch("{sessionName}/Move")] + public async Task Move([FromRoute] string sessionName, [FromBody] MovePieceCommand command) + { + var userId = User.GetShogiUserId(); + var session = await sessionRepository.ReadSession(sessionName); + + if (session == null) return this.NotFound("Shogi session does not exist."); + + if (!session.IsSeated(userId)) return this.StatusCode(StatusCodes.Status403Forbidden, "Player is not a member of the Shogi session."); + + try + { + if (command.PieceFromHand.HasValue) + { + session.Board.Move(command.PieceFromHand.Value.ToDomain(), command.To); + } + else + { + session.Board.Move(command.From!, command.To, command.IsPromotion ?? false); + } + } + catch (InvalidOperationException e) + { + return this.Conflict(e.Message); + } + await sessionRepository.CreateMove(sessionName, command); + await communicationManager.BroadcastToPlayers( + new PlayerHasMovedMessage + { + PlayerName = userId, + SessionName = session.Name, + }, + session.Player1, + session.Player2); + + return this.NoContent(); + } } diff --git a/Shogi.Api/Program.cs b/Shogi.Api/Program.cs index 4dc2ca6..bea291d 100644 --- a/Shogi.Api/Program.cs +++ b/Shogi.Api/Program.cs @@ -33,7 +33,6 @@ namespace Shogi.Api .AllowCredentials(); }); }); - ConfigureAuthentication(builder); ConfigureControllers(builder); ConfigureSwagger(builder); diff --git a/Shogi.Api/Repositories/QueryRepository.cs b/Shogi.Api/Repositories/QueryRepository.cs index 25795c3..1ba6240 100644 --- a/Shogi.Api/Repositories/QueryRepository.cs +++ b/Shogi.Api/Repositories/QueryRepository.cs @@ -11,7 +11,8 @@ public class QueryRepository : IQueryRespository public QueryRepository(IConfiguration configuration) { - connectionString = configuration.GetConnectionString("ShogiDatabase"); + var connectionString = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("No database configured for QueryRepository."); + this.connectionString = connectionString; } public async Task ReadSessionPlayerCount(string playerName) diff --git a/Shogi.Api/Repositories/SessionRepository.cs b/Shogi.Api/Repositories/SessionRepository.cs index 409290f..a530dcf 100644 --- a/Shogi.Api/Repositories/SessionRepository.cs +++ b/Shogi.Api/Repositories/SessionRepository.cs @@ -9,89 +9,103 @@ namespace Shogi.Api.Repositories; public class SessionRepository : ISessionRepository { - private readonly string connectionString; + private readonly string connectionString; - public SessionRepository(IConfiguration configuration) - { - connectionString = configuration.GetConnectionString("ShogiDatabase"); - } - - public async Task CreateSession(Session session) - { - using var connection = new SqlConnection(connectionString); - await connection.ExecuteAsync( - "session.CreateSession", - new - { - session.Name, - Player1Name = session.Player1, - }, - commandType: CommandType.StoredProcedure); - } - - public async Task DeleteSession(string name) - { - using var connection = new SqlConnection(connectionString); - await connection.ExecuteAsync( - "session.DeleteSession", - new { Name = name }, - commandType: CommandType.StoredProcedure); - } - - public async Task ReadSession(string name) - { - using var connection = new SqlConnection(connectionString); - var results = await connection.QueryMultipleAsync( - "session.ReadSession", - new { Name = name }, - commandType: CommandType.StoredProcedure); - - var sessionDtos = await results.ReadAsync(); - if (!sessionDtos.Any()) return null; - var dto = sessionDtos.First(); - var session = new Session(dto.Name, dto.Player1); - if (!string.IsNullOrWhiteSpace(dto.Player2)) session.AddPlayer2(dto.Player2); - - var moveDtos = await results.ReadAsync(); - foreach (var move in moveDtos) + public SessionRepository(IConfiguration configuration) { - if (move.PieceFromHand.HasValue) - { - session.Board.Move(move.PieceFromHand.Value, move.To); - } - else if (move.From != null) - { - session.Board.Move(move.From, move.To, false); - } - else - { - throw new InvalidOperationException($"Corrupt data during {nameof(ReadSession)}"); - } + connectionString = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("Database connection string not configured."); } - return session; - } - public async Task CreateMove(string sessionName, Contracts.Api.MovePieceCommand command) - { - using var connection = new SqlConnection(connectionString); - await connection.ExecuteAsync( - "session.CreateMove", - new - { - command.To, - command.From, - command.IsPromotion, - command.PieceFromHand, - SessionName = sessionName - }, - commandType: CommandType.StoredProcedure); - } + public async Task CreateSession(Session session) + { + using var connection = new SqlConnection(connectionString); + await connection.ExecuteAsync( + "session.CreateSession", + new + { + session.Name, + Player1Name = session.Player1, + }, + commandType: CommandType.StoredProcedure); + } + + public async Task DeleteSession(string name) + { + using var connection = new SqlConnection(connectionString); + await connection.ExecuteAsync( + "session.DeleteSession", + new { Name = name }, + commandType: CommandType.StoredProcedure); + } + + public async Task ReadSession(string name) + { + using var connection = new SqlConnection(connectionString); + var results = await connection.QueryMultipleAsync( + "session.ReadSession", + new { Name = name }, + commandType: CommandType.StoredProcedure); + + var sessionDtos = await results.ReadAsync(); + if (!sessionDtos.Any()) return null; + var dto = sessionDtos.First(); + var session = new Session(dto.Name, dto.Player1); + if (!string.IsNullOrWhiteSpace(dto.Player2)) session.AddPlayer2(dto.Player2); + + var moveDtos = await results.ReadAsync(); + foreach (var move in moveDtos) + { + if (move.PieceFromHand.HasValue) + { + session.Board.Move(move.PieceFromHand.Value, move.To); + } + else if (move.From != null) + { + session.Board.Move(move.From, move.To, false); + } + else + { + throw new InvalidOperationException($"Corrupt data during {nameof(ReadSession)}"); + } + } + return session; + } + + public async Task CreateMove(string sessionName, MovePieceCommand command) + { + using var connection = new SqlConnection(connectionString); + await connection.ExecuteAsync( + "session.CreateMove", + new + { + command.To, + command.From, + command.IsPromotion, + command.PieceFromHand, + SessionName = sessionName + }, + commandType: CommandType.StoredProcedure); + } + + public async Task SetPlayer2(string sessionName, string player2Name) + { + using var connection = new SqlConnection(connectionString); + await connection.ExecuteAsync( + "session.SetPlayer2", + new + { + SessionName = sessionName, + Player2Name = player2Name + }, + commandType: CommandType.StoredProcedure); + } } public interface ISessionRepository { - Task CreateMove(string sessionName, MovePieceCommand command); - Task CreateSession(Session session); - Task DeleteSession(string name); - Task ReadSession(string name); + Task CreateMove(string sessionName, MovePieceCommand command); + Task CreateSession(Session session); + Task DeleteSession(string name); + Task ReadSession(string name); + Task SetPlayer2(string sessionName, string player2Name); } \ No newline at end of file diff --git a/Shogi.Database/Session/Stored Procedures/SetPlayer2.sql b/Shogi.Database/Session/Stored Procedures/SetPlayer2.sql new file mode 100644 index 0000000..afad1c2 --- /dev/null +++ b/Shogi.Database/Session/Stored Procedures/SetPlayer2.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [session].[SetPlayer2] + @SessionName [session].[SessionName], + @Player2Name [user].[UserName] NULL +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @player2Id BIGINT; + SELECT @player2Id = Id FROM [user].[User] WHERE [Name] = @Player2Name; + + UPDATE sess + SET Player2Id = @player2Id + FROM [session].[Session] sess + WHERE sess.[Name] = @SessionName; + +END diff --git a/Shogi.Database/Session/Stored Procedures/UpdateSession.sql b/Shogi.Database/Session/Stored Procedures/UpdateSession.sql deleted file mode 100644 index 0575e4e..0000000 --- a/Shogi.Database/Session/Stored Procedures/UpdateSession.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE PROCEDURE [dbo].[UpdateSession] - @SessionName [session].[SessionName], - @BoardStateJson [session].[JsonDocument] -AS -BEGIN - SET NOCOUNT ON; - - UPDATE bs - SET bs.Document = @BoardStateJson - FROM [session].[BoardState] bs - INNER JOIN [session].[Session] s on s.Id = bs.SessionId - WHERE s.Name = @SessionName; - -END diff --git a/Shogi.Database/Shogi.Database.sqlproj b/Shogi.Database/Shogi.Database.sqlproj index b21c114..51ec8c5 100644 --- a/Shogi.Database/Shogi.Database.sqlproj +++ b/Shogi.Database/Shogi.Database.sqlproj @@ -82,7 +82,7 @@ - + diff --git a/Shogi.UI/Pages/Home/Api/IShogiApi.cs b/Shogi.UI/Pages/Home/Api/IShogiApi.cs index 02cd1a6..1510035 100644 --- a/Shogi.UI/Pages/Home/Api/IShogiApi.cs +++ b/Shogi.UI/Pages/Home/Api/IShogiApi.cs @@ -12,5 +12,6 @@ public interface IShogiApi Task GetToken(WhichAccountPlatform whichAccountPlatform); Task GuestLogout(); Task Move(string sessionName, MovePieceCommand move); - Task PostSession(string name, bool isPrivate); + Task PatchJoinGame(string name); + Task PostSession(string name, bool isPrivate); } \ No newline at end of file diff --git a/Shogi.UI/Pages/Home/Api/ShogiApi.cs b/Shogi.UI/Pages/Home/Api/ShogiApi.cs index fa578ff..a9562a2 100644 --- a/Shogi.UI/Pages/Home/Api/ShogiApi.cs +++ b/Shogi.UI/Pages/Home/Api/ShogiApi.cs @@ -28,7 +28,7 @@ namespace Shogi.UI.Pages.Home.Api { WhichAccountPlatform.Guest => clientFactory.CreateClient(GuestClientName), WhichAccountPlatform.Microsoft => clientFactory.CreateClient(MsalClientName), - _ => clientFactory.CreateClient(GuestClientName) + _ => throw new InvalidOperationException("AccountState.User must not be null during API call.") }; public async Task GuestLogout() @@ -49,7 +49,7 @@ namespace Shogi.UI.Pages.Home.Api public async Task GetSessionsPlayerCount() { - var response = await HttpClient.GetAsync(new Uri("Sessions/PlayerCount", UriKind.Relative)); + var response = await HttpClient.GetAsync(RelativeUri("Sessions/PlayerCount")); if (response.IsSuccessStatusCode) { return await response.Content.ReadFromJsonAsync(serializerOptions); @@ -57,12 +57,15 @@ namespace Shogi.UI.Pages.Home.Api return null; } + /// + /// Logs the user into the API and returns a token which can be used to request a socket connection. + /// public async Task GetToken(WhichAccountPlatform whichAccountPlatform) { var httpClient = whichAccountPlatform == WhichAccountPlatform.Microsoft ? clientFactory.CreateClient(MsalClientName) : clientFactory.CreateClient(GuestClientName); - var response = await httpClient.GetFromJsonAsync(new Uri("User/Token", UriKind.Relative), serializerOptions); + var response = await httpClient.GetFromJsonAsync(RelativeUri("User/Token"), serializerOptions); return response; } @@ -73,11 +76,19 @@ namespace Shogi.UI.Pages.Home.Api public async Task PostSession(string name, bool isPrivate) { - var response = await HttpClient.PostAsJsonAsync(new Uri("Sessions", UriKind.Relative), new CreateSessionCommand + var response = await HttpClient.PostAsJsonAsync(RelativeUri("Sessions"), new CreateSessionCommand { Name = name, }); return response.StatusCode; } + + public async Task PatchJoinGame(string name) + { + var response = await HttpClient.PatchAsync(RelativeUri($"Sessions/{name}/Join"), null); + return response.StatusCode; + } + + private static Uri RelativeUri(string path) => new Uri(path, UriKind.Relative); } } diff --git a/Shogi.UI/Pages/Home/GameBoard/GameBoard.razor b/Shogi.UI/Pages/Home/GameBoard/GameBoard.razor index 6345ec6..c74d924 100644 --- a/Shogi.UI/Pages/Home/GameBoard/GameBoard.razor +++ b/Shogi.UI/Pages/Home/GameBoard/GameBoard.razor @@ -15,7 +15,7 @@ else if (isSpectating) } else { - + } @@ -29,7 +29,12 @@ else protected override async Task OnParametersSetAsync() { - if (!string.IsNullOrWhiteSpace(SessionName)) + await RefetchSession(); + } + + async Task RefetchSession() + { + if (!string.IsNullOrWhiteSpace(SessionName)) { this.session = await ShogiApi.GetSession(SessionName); if (this.session != null) @@ -42,4 +47,6 @@ else } } } + + } diff --git a/Shogi.UI/Pages/Home/GameBoard/GameBoardPresentation.razor b/Shogi.UI/Pages/Home/GameBoard/GameBoardPresentation.razor index d0c2181..c961965 100644 --- a/Shogi.UI/Pages/Home/GameBoard/GameBoardPresentation.razor +++ b/Shogi.UI/Pages/Home/GameBoard/GameBoardPresentation.razor @@ -1,5 +1,6 @@ @using Shogi.Contracts.Types; @inject PromotePrompt PromotePrompt; +@inject AccountState AccountState;
@if (IsSpectating) @@ -65,25 +66,56 @@ @if (Session != null) { }
@@ -94,8 +126,9 @@ [Parameter] public Session? Session { get; set; } [Parameter] public string? SelectedPosition { get; set; } // TODO: Exchange these OnClick actions for events like "SelectionChangedEvent" and "MoveFromBoardEvent" and "MoveFromHandEvent". - [Parameter] public Action? OnClickTile { get; set; } - [Parameter] public Action? OnClickHand { get; set; } + [Parameter] public Func? OnClickTile { get; set; } + [Parameter] public Func? OnClickHand { get; set; } + [Parameter] public Func? OnClickJoinGame { get; set; } static readonly string[] Files = new[] { "A", "B", "C", "D", "E", "F", "G", "H", "I" }; @@ -124,4 +157,5 @@ private Action OnClickTileInternal(Piece? piece, string position) => () => OnClickTile?.Invoke(piece, position); private Action OnClickHandInternal(Piece piece) => () => OnClickHand?.Invoke(piece); + private Action OnClickJoinGameInternal() => () => OnClickJoinGame?.Invoke(); } diff --git a/Shogi.UI/Pages/Home/GameBoard/GameboardPresentation.razor.css b/Shogi.UI/Pages/Home/GameBoard/GameboardPresentation.razor.css index 6e7ca4c..dcf4002 100644 --- a/Shogi.UI/Pages/Home/GameBoard/GameboardPresentation.razor.css +++ b/Shogi.UI/Pages/Home/GameBoard/GameboardPresentation.razor.css @@ -14,6 +14,7 @@ .side-board { grid-area: side-board; } + .icons { grid-area: icons; } @@ -90,12 +91,19 @@ .side-board { display: grid; - grid-template-rows: auto 1fr auto; + grid-auto-rows: 1fr; + padding: 1rem; + background-color: var(--contrast-color); } + .side-board .player-area { + display: grid; + } + .side-board .hand { - display: flex; - flex-wrap: wrap; + display: grid; + grid-auto-columns: 1fr; + border: 1px solid #ccc; } .promote-prompt { @@ -116,9 +124,5 @@ } .spectating { - position: absolute; - right: 0; - top: 0; - z-index: 100; color: var(--contrast-color) } diff --git a/Shogi.UI/Pages/Home/GameBoard/SeatedGameBoard.razor b/Shogi.UI/Pages/Home/GameBoard/SeatedGameBoard.razor index b10d553..af5bdc2 100644 --- a/Shogi.UI/Pages/Home/GameBoard/SeatedGameBoard.razor +++ b/Shogi.UI/Pages/Home/GameBoard/SeatedGameBoard.razor @@ -1,14 +1,22 @@ @using Shogi.Contracts.Api; @using Shogi.Contracts.Types; @using System.Text.RegularExpressions; +@using System.Net; @inject PromotePrompt PromotePrompt; @inject IShogiApi ShogiApi; - + @code { - [Parameter] public WhichPlayer Perspective { get; set; } - [Parameter] public Session Session { get; set; } + [Parameter, EditorRequired] + public WhichPlayer Perspective { get; set; } + [Parameter, EditorRequired] + public Session Session { get; set; } + [Parameter] public Func? OnRefetchSession { get; set; } private bool IsMyTurn => Session?.BoardState.WhoseTurn == Perspective; private string? selectedBoardPosition; private WhichPiece? selectedPieceFromHand; @@ -26,7 +34,7 @@ return false; } - async void OnClickTile(Piece? piece, string position) + async Task OnClickTile(Piece? piece, string position) { if (!IsMyTurn) return; @@ -64,8 +72,21 @@ } } - void OnClickHand(Piece piece) + async Task OnClickHand(Piece piece) { selectedPieceFromHand = piece.WhichPiece; + await Task.CompletedTask; + } + + async Task OnClickJoinGame() + { + if (Session != null && OnRefetchSession != null) + { + var status = await ShogiApi.PatchJoinGame(Session.SessionName); + if (status == HttpStatusCode.OK) + { + await OnRefetchSession.Invoke(); + } + } } } diff --git a/Shogi.UI/Program.cs b/Shogi.UI/Program.cs index f79ea61..8c3f8c9 100644 --- a/Shogi.UI/Program.cs +++ b/Shogi.UI/Program.cs @@ -28,9 +28,6 @@ static void ConfigureDependencies(IServiceCollection services, IConfiguration co services .AddHttpClient(ShogiApi.GuestClientName, client => client.BaseAddress = shogiApiUrl) .AddHttpMessageHandler(); - //services - // .AddHttpClient(ShogiApi.AnonymousClientName, client => client.BaseAddress = shogiApiUrl) - // .AddHttpMessageHandler(); // Authorization services.AddMsalAuthentication(options => diff --git a/Shogi.UI/Shared/ShogiSocket.cs b/Shogi.UI/Shared/ShogiSocket.cs index 3dbb5d0..4d1e911 100644 --- a/Shogi.UI/Shared/ShogiSocket.cs +++ b/Shogi.UI/Shared/ShogiSocket.cs @@ -9,82 +9,89 @@ namespace Shogi.UI.Shared; public class ShogiSocket : IDisposable { - public event EventHandler? OnCreateGameMessage; + public event EventHandler? OnCreateGameMessage; - private readonly ClientWebSocket socket; - private readonly JsonSerializerOptions serializerOptions; - private readonly UriBuilder uriBuilder; - private readonly CancellationTokenSource cancelToken; - private readonly IMemoryOwner memoryOwner; - private bool disposedValue; + private readonly ClientWebSocket socket; + private readonly JsonSerializerOptions serializerOptions; + private readonly UriBuilder uriBuilder; + private readonly CancellationTokenSource cancelToken; + private readonly IMemoryOwner memoryOwner; + private bool disposedValue; - public ShogiSocket(IConfiguration configuration, ClientWebSocket socket, JsonSerializerOptions serializerOptions) - { - this.socket = socket; - this.serializerOptions = serializerOptions; - this.uriBuilder = new UriBuilder(configuration["SocketUrl"]); - this.cancelToken = new CancellationTokenSource(); - this.memoryOwner = MemoryPool.Shared.Rent(1024 * 2); - } + public ShogiSocket(IConfiguration configuration, ClientWebSocket socket, JsonSerializerOptions serializerOptions) + { + this.socket = socket; + this.serializerOptions = serializerOptions; + this.uriBuilder = new UriBuilder(configuration["SocketUrl"] ?? throw new InvalidOperationException("SocketUrl configuration is missing.")); + this.cancelToken = new CancellationTokenSource(); + this.memoryOwner = MemoryPool.Shared.Rent(1024 * 2); + } - public async Task OpenAsync(string token) - { - uriBuilder.Query = new QueryBuilder + public async Task OpenAsync(string token) + { + uriBuilder.Query = new QueryBuilder { { "token", token } }.ToQueryString().Value; - await socket.ConnectAsync(this.uriBuilder.Uri, cancelToken.Token); - Console.WriteLine("CONNECTED"); - Listen(); - } - - private async void Listen() - { - while (socket.State == WebSocketState.Open && !cancelToken.IsCancellationRequested) - { - var result = await socket.ReceiveAsync(this.memoryOwner.Memory, cancelToken.Token); - var memory = this.memoryOwner.Memory[..result.Count].ToArray(); - var action = JsonDocument - .Parse(memory[..result.Count]) - .RootElement - .GetProperty(nameof(ISocketResponse.Action)) - .Deserialize(); - - switch (action) - { - case SocketAction.SessionCreated: - Console.WriteLine("Session created event."); - this.OnCreateGameMessage?.Invoke(this, JsonSerializer.Deserialize(memory, this.serializerOptions)!); - break; - default: - break; - } + await socket.ConnectAsync(this.uriBuilder.Uri, cancelToken.Token); + Console.WriteLine("Socket Connected"); + // Fire and forget! I'm way too lazy to write my own javascript interop to a web worker. Nooo thanks. + var listening = Listen().ContinueWith(antecedent => + { + if (antecedent.Exception != null) + { + throw antecedent.Exception; + } + }, TaskContinuationOptions.OnlyOnFaulted); } - if (!cancelToken.IsCancellationRequested) - { - throw new InvalidOperationException("Stopped socket listening without cancelling."); - } - } - protected virtual void Dispose(bool disposing) - { - if (!disposedValue) + private async Task Listen() { - if (disposing) - { - cancelToken.Cancel(); - socket.Dispose(); - memoryOwner.Dispose(); - } - disposedValue = true; - } - } + while (socket.State == WebSocketState.Open && !cancelToken.IsCancellationRequested) + { + var result = await socket.ReceiveAsync(this.memoryOwner.Memory, cancelToken.Token); + var memory = this.memoryOwner.Memory[..result.Count].ToArray(); + var action = JsonDocument + .Parse(memory[..result.Count]) + .RootElement + .GetProperty(nameof(ISocketResponse.Action)) + .Deserialize(); - public void Dispose() - { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(disposing: true); - GC.SuppressFinalize(this); - } + switch (action) + { + case SocketAction.SessionCreated: + Console.WriteLine("Session created event."); + this.OnCreateGameMessage?.Invoke(this, JsonSerializer.Deserialize(memory, this.serializerOptions)!); + break; + default: + break; + } + } + if (!cancelToken.IsCancellationRequested) + { + throw new InvalidOperationException("Stopped socket listening without cancelling."); + } + } + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + cancelToken.Cancel(); + socket.Dispose(); + memoryOwner.Dispose(); + } + disposedValue = true; + } + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } } diff --git a/Shogi.UI/wwwroot/css/app.css b/Shogi.UI/wwwroot/css/app.css index 51e5cec..d99d135 100644 --- a/Shogi.UI/wwwroot/css/app.css +++ b/Shogi.UI/wwwroot/css/app.css @@ -121,3 +121,7 @@ button.btn-link { button.btn.btn-link:not(:hover) { text-decoration: none; } + +.place-self-center { + place-self: center; +} diff --git a/Tests/AcceptanceTests/AcceptanceTests.cs b/Tests/AcceptanceTests/AcceptanceTests.cs index be79d7c..9cc5578 100644 --- a/Tests/AcceptanceTests/AcceptanceTests.cs +++ b/Tests/AcceptanceTests/AcceptanceTests.cs @@ -12,16 +12,62 @@ namespace Shogi.AcceptanceTests; public class AcceptanceTests : IClassFixture #pragma warning restore xUnit1033 { - private readonly GuestTestFixture fixture; + private readonly HttpClient guest1HttpClient; + private readonly HttpClient guest2HttpClient; private readonly ITestOutputHelper console; public AcceptanceTests(GuestTestFixture fixture, ITestOutputHelper console) { - this.fixture = fixture; + this.guest1HttpClient = fixture.Guest1ServiceClient; + this.guest2HttpClient = fixture.Guest2ServiceClient; this.console = console; } - private HttpClient Service => fixture.Service; + [Fact] + public async Task JoinSession_Player2IsNotSet_SetsPlayer2() + { + try + { + // Arrange + await SetupTestSession(); + + // Act + var joinResponse = await guest2HttpClient.PatchAsync(new Uri("Sessions/Acceptance Tests/Join", UriKind.Relative), null); + + // Assert + joinResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var readSessionResponse = await ReadTestSession(); + readSessionResponse.Session.Player2.Should().NotBeNullOrEmpty(); + } + finally + { + await DeleteTestSession(); + } + } + + [Fact] + public async Task JoinSession_SessionIsFull_Conflict() + { + try + { + // Arrange + await SetupTestSession(); + var joinResponse = await guest2HttpClient.PatchAsync(new Uri("Sessions/Acceptance Tests/Join", UriKind.Relative), null); + joinResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var readSessionResponse = await ReadTestSession(); + readSessionResponse.Session.Player2.Should().NotBeNullOrEmpty(); + + // Act + joinResponse = await guest2HttpClient.PatchAsync(new Uri("Sessions/Acceptance Tests/Join", UriKind.Relative), null); + + // Assert + joinResponse.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + finally + { + await DeleteTestSession(); + } + } [Fact] public async Task ReadSessionsPlayerCount() @@ -32,7 +78,7 @@ public class AcceptanceTests : IClassFixture await SetupTestSession(); // Act - var readAllResponse = await Service + var readAllResponse = await guest1HttpClient .GetFromJsonAsync(new Uri("Sessions/PlayerCount", UriKind.Relative), Contracts.ShogiApiJsonSerializerSettings.SystemTextJsonSerializerOptions); @@ -42,7 +88,7 @@ public class AcceptanceTests : IClassFixture .PlayerHasJoinedSessions .Should() .ContainSingle(session => session.Name == "Acceptance Tests" && session.PlayerCount == 1); - readAllResponse.AllOtherSessions.Should().BeEmpty(); + readAllResponse.AllOtherSessions.Should().NotBeNull(); } finally { @@ -60,7 +106,7 @@ public class AcceptanceTests : IClassFixture await SetupTestSession(); // Act - var response = await Service.GetFromJsonAsync( + var response = await guest1HttpClient.GetFromJsonAsync( new Uri("Sessions/Acceptance Tests", UriKind.Relative), Contracts.ShogiApiJsonSerializerSettings.SystemTextJsonSerializerOptions); @@ -273,7 +319,7 @@ public class AcceptanceTests : IClassFixture }; // Act - var response = await Service.PatchAsync(new Uri("Sessions/Acceptance Tests/Move", UriKind.Relative), JsonContent.Create(movePawnCommand)); + var response = await guest1HttpClient.PatchAsync(new Uri("Sessions/Acceptance Tests/Move", UriKind.Relative), JsonContent.Create(movePawnCommand)); response.StatusCode.Should().Be(HttpStatusCode.NoContent, because: await response.Content.ReadAsStringAsync()); // Assert @@ -293,7 +339,7 @@ public class AcceptanceTests : IClassFixture private async Task SetupTestSession() { - var createResponse = await Service.PostAsJsonAsync( + var createResponse = await guest1HttpClient.PostAsJsonAsync( new Uri("Sessions", UriKind.Relative), new CreateSessionCommand { Name = "Acceptance Tests" }, Contracts.ShogiApiJsonSerializerSettings.SystemTextJsonSerializerOptions); @@ -302,12 +348,12 @@ public class AcceptanceTests : IClassFixture private Task ReadTestSession() { - return Service.GetFromJsonAsync(new Uri("Sessions/Acceptance Tests", UriKind.Relative))!; + return guest1HttpClient.GetFromJsonAsync(new Uri("Sessions/Acceptance Tests", UriKind.Relative))!; } private async Task DeleteTestSession() { - var response = await Service.DeleteAsync(new Uri("Sessions/Acceptance Tests", UriKind.Relative)); + var response = await guest1HttpClient.DeleteAsync(new Uri("Sessions/Acceptance Tests", UriKind.Relative)); response.StatusCode.Should().Be(HttpStatusCode.NoContent, because: await response.Content.ReadAsStringAsync()); } diff --git a/Tests/AcceptanceTests/TestSetup/GuestTestFixture.cs b/Tests/AcceptanceTests/TestSetup/GuestTestFixture.cs index 423326d..5797fb6 100644 --- a/Tests/AcceptanceTests/TestSetup/GuestTestFixture.cs +++ b/Tests/AcceptanceTests/TestSetup/GuestTestFixture.cs @@ -15,22 +15,36 @@ public class GuestTestFixture : IAsyncLifetime, IDisposable .AddJsonFile("appsettings.json") .Build(); - Service = new HttpClient + var baseUrl = Configuration["ServiceUrl"] ?? throw new InvalidOperationException("ServiceUrl configuration missing."); + var baseAddress = new Uri(baseUrl, UriKind.Absolute); + Guest1ServiceClient = new HttpClient { - BaseAddress = new Uri(Configuration["ServiceUrl"], UriKind.Absolute) + BaseAddress = baseAddress }; + Guest2ServiceClient = new HttpClient + { + BaseAddress = baseAddress + }; } public IConfiguration Configuration { get; private set; } - public HttpClient Service { get; } + public HttpClient Guest2ServiceClient { get; } + public HttpClient Guest1ServiceClient { get; } - public async Task InitializeAsync() + public async Task InitializeAsync() { - // Log in as a guest account and retain the session cookie for future requests. - var loginResponse = await Service.GetAsync(new Uri("User/LoginAsGuest", UriKind.Relative)); + // Log in as some guest accounts and retain the session cookie for future requests. + var guestLoginUri = new Uri("User/LoginAsGuest", UriKind.Relative); + + var loginResponse = await Guest1ServiceClient.GetAsync(guestLoginUri); loginResponse.IsSuccessStatusCode.Should().BeTrue(because: "Guest accounts should work"); - var guestSessionCookie = loginResponse.Headers.GetValues("Set-Cookie").SingleOrDefault(); - Service.DefaultRequestHeaders.Add("Set-Cookie", guestSessionCookie); + var guestSessionCookie = loginResponse.Headers.GetValues("Set-Cookie").Single(); + Guest1ServiceClient.DefaultRequestHeaders.Add("Set-Cookie", guestSessionCookie); + + loginResponse = await Guest2ServiceClient.GetAsync(guestLoginUri); + loginResponse.IsSuccessStatusCode.Should().BeTrue(because: "Guest accounts should work twice"); + guestSessionCookie = loginResponse.Headers.GetValues("Set-Cookie").Single(); + Guest2ServiceClient.DefaultRequestHeaders.Add("Set-Cookie", guestSessionCookie); } protected virtual void Dispose(bool disposing) @@ -39,7 +53,7 @@ public class GuestTestFixture : IAsyncLifetime, IDisposable { if (disposing) { - Service.Dispose(); + Guest1ServiceClient.Dispose(); } disposedValue = true;