Working on "Join Game" feature.

This commit is contained in:
2023-01-23 17:25:41 -06:00
parent 26fd955aa4
commit 11b387b928
18 changed files with 509 additions and 344 deletions

View File

@@ -12,5 +12,6 @@ public interface IShogiApi
Task<CreateTokenResponse?> GetToken(WhichAccountPlatform whichAccountPlatform);
Task GuestLogout();
Task Move(string sessionName, MovePieceCommand move);
Task<HttpStatusCode> PostSession(string name, bool isPrivate);
Task<HttpStatusCode> PatchJoinGame(string name);
Task<HttpStatusCode> PostSession(string name, bool isPrivate);
}

View File

@@ -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<ReadSessionsPlayerCountResponse?> 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<ReadSessionsPlayerCountResponse>(serializerOptions);
@@ -57,12 +57,15 @@ namespace Shogi.UI.Pages.Home.Api
return null;
}
/// <summary>
/// Logs the user into the API and returns a token which can be used to request a socket connection.
/// </summary>
public async Task<CreateTokenResponse?> GetToken(WhichAccountPlatform whichAccountPlatform)
{
var httpClient = whichAccountPlatform == WhichAccountPlatform.Microsoft
? clientFactory.CreateClient(MsalClientName)
: clientFactory.CreateClient(GuestClientName);
var response = await httpClient.GetFromJsonAsync<CreateTokenResponse>(new Uri("User/Token", UriKind.Relative), serializerOptions);
var response = await httpClient.GetFromJsonAsync<CreateTokenResponse>(RelativeUri("User/Token"), serializerOptions);
return response;
}
@@ -73,11 +76,19 @@ namespace Shogi.UI.Pages.Home.Api
public async Task<HttpStatusCode> 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<HttpStatusCode> 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);
}
}

View File

@@ -15,7 +15,7 @@ else if (isSpectating)
}
else
{
<SeatedGameBoard Perspective="perspective" Session="session" />
<SeatedGameBoard Perspective="perspective" Session="session" OnRefetchSession="RefetchSession" />
}
@@ -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
}
}
}
}

View File

@@ -1,5 +1,6 @@
@using Shogi.Contracts.Types;
@inject PromotePrompt PromotePrompt;
@inject AccountState AccountState;
<article class="game-board">
@if (IsSpectating)
@@ -65,25 +66,56 @@
@if (Session != null)
{
<aside class="side-board">
<div class="hand">
@foreach (var piece in OpponentHand)
<div class="player-area">
<div class="hand">
@if (OpponentHand.Any())
{
@foreach (var piece in OpponentHand)
{
<div class="tile">
<GamePiece Piece="piece" Perspective="Perspective" />
</div>
}
}
else
{
<i class="place-self-center">Hand is empty.</i>
}
</div>
</div>
<div class="spacer place-self-center">
</div>
<div class="player-area">
@if (Session.Player2 == null && Session.Player1 != AccountState.User?.Id)
{
<div class="tile">
<GamePiece Piece="piece" Perspective="Perspective" />
<div class="place-self-center">
<p>Seat is Empty</p>
<button @onclick="OnClickJoinGameInternal()">Join Game</button>
</div>
}
else
{
<div class="hand">
@if (UserHand.Any())
{
@foreach (var piece in UserHand)
{
<div class="title" @onclick="OnClickHandInternal(piece)">
<GamePiece Piece="piece" Perspective="Perspective" />
</div>
}
}
else
{
<i class="place-self-center">Hand is empty.</i>
}
</div>
}
</div>
<div class="spacer" />
<div class="hand">
@foreach (var piece in UserHand)
{
<div class="title" @onclick="OnClickHandInternal(piece)">
<GamePiece Piece="piece" Perspective="Perspective" />
</div>
}
</div>
</aside>
}
</article>
@@ -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<Piece?, string>? OnClickTile { get; set; }
[Parameter] public Action<Piece>? OnClickHand { get; set; }
[Parameter] public Func<Piece?, string, Task>? OnClickTile { get; set; }
[Parameter] public Func<Piece, Task>? OnClickHand { get; set; }
[Parameter] public Func<Task>? 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();
}

View File

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

View File

@@ -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;
<GameBoardPresentation OnClickHand="OnClickHand" OnClickTile="OnClickTile" Session="Session" Perspective="Perspective" />
<GameBoardPresentation Session="Session"
Perspective="Perspective"
OnClickHand="OnClickHand"
OnClickTile="OnClickTile"
OnClickJoinGame="OnClickJoinGame" />
@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<Task>? 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();
}
}
}
}

View File

@@ -28,9 +28,6 @@ static void ConfigureDependencies(IServiceCollection services, IConfiguration co
services
.AddHttpClient(ShogiApi.GuestClientName, client => client.BaseAddress = shogiApiUrl)
.AddHttpMessageHandler<CookieCredentialsMessageHandler>();
//services
// .AddHttpClient(ShogiApi.AnonymousClientName, client => client.BaseAddress = shogiApiUrl)
// .AddHttpMessageHandler<CookieCredentialsMessageHandler>();
// Authorization
services.AddMsalAuthentication(options =>

View File

@@ -9,82 +9,89 @@ namespace Shogi.UI.Shared;
public class ShogiSocket : IDisposable
{
public event EventHandler<SessionCreatedSocketMessage>? OnCreateGameMessage;
public event EventHandler<SessionCreatedSocketMessage>? OnCreateGameMessage;
private readonly ClientWebSocket socket;
private readonly JsonSerializerOptions serializerOptions;
private readonly UriBuilder uriBuilder;
private readonly CancellationTokenSource cancelToken;
private readonly IMemoryOwner<byte> memoryOwner;
private bool disposedValue;
private readonly ClientWebSocket socket;
private readonly JsonSerializerOptions serializerOptions;
private readonly UriBuilder uriBuilder;
private readonly CancellationTokenSource cancelToken;
private readonly IMemoryOwner<byte> 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<byte>.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<byte>.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<SocketAction>();
switch (action)
{
case SocketAction.SessionCreated:
Console.WriteLine("Session created event.");
this.OnCreateGameMessage?.Invoke(this, JsonSerializer.Deserialize<SessionCreatedSocketMessage>(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<SocketAction>();
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<SessionCreatedSocketMessage>(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);
}
}

View File

@@ -121,3 +121,7 @@ button.btn-link {
button.btn.btn-link:not(:hover) {
text-decoration: none;
}
.place-self-center {
place-self: center;
}