Replace custom socket implementation with SignalR.

Replace MSAL and custom cookie auth with Microsoft.Identity.EntityFramework
Also some UI redesign to accommodate different login experience.
This commit is contained in:
2024-08-25 03:46:44 +00:00
parent d688afaeae
commit 51d234d871
172 changed files with 3857 additions and 4045 deletions

View File

@@ -1,136 +0,0 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Shogi.UI.Pages.Home.Api;
using Shogi.UI.Shared;
namespace Shogi.UI.Pages.Home.Account;
public class AccountManager
{
private readonly AccountState accountState;
private readonly IShogiApi shogiApi;
private readonly ILocalStorage localStorage;
private readonly AuthenticationStateProvider authState;
private readonly NavigationManager navigation;
private readonly ShogiSocket shogiSocket;
public AccountManager(
AccountState accountState,
IShogiApi unauthenticatedClient,
AuthenticationStateProvider authState,
ILocalStorage localStorage,
NavigationManager navigation,
ShogiSocket shogiSocket)
{
this.accountState = accountState;
this.shogiApi = unauthenticatedClient;
this.authState = authState;
this.localStorage = localStorage;
this.navigation = navigation;
this.shogiSocket = shogiSocket;
}
private Task SetUser(User user) => accountState.SetUser(user);
public async Task LoginWithGuestAccount()
{
var response = await shogiApi.GetToken(WhichAccountPlatform.Guest);
if (response != null)
{
await SetUser(new User
{
DisplayName = response.DisplayName,
Id = response.UserId,
WhichAccountPlatform = WhichAccountPlatform.Guest
});
await localStorage.SetAccountPlatform(WhichAccountPlatform.Guest);
// TODO: OpenAsync() sometimes doesn't return, probably because of the fire'n'forget task inside it. Figure that out.
await shogiSocket.OpenAsync(response.OneTimeToken.ToString());
}
else
{
throw new InvalidOperationException("Failed to get token from server during guest login.");
}
}
public async Task LoginWithMicrosoftAccount()
{
var state = await authState.GetAuthenticationStateAsync();
if (state?.User?.Identity?.Name == null || state.User?.Identity?.IsAuthenticated == false)
{
// Set the login platform so that we know to log in with microsoft after being redirected away from the UI.
await localStorage.SetAccountPlatform(WhichAccountPlatform.Microsoft);
navigation.NavigateToLogin("authentication/login");
return;
}
}
/// <summary>
/// Try to log in with the account used from the previous browser session.
/// </summary>
/// <returns>True if login succeeded.</returns>
public async Task<bool> TryLoginSilentAsync()
{
var platform = await localStorage.GetAccountPlatform();
if (platform == WhichAccountPlatform.Guest)
{
var response = await shogiApi.GetToken(WhichAccountPlatform.Guest);
if (response != null)
{
await accountState.SetUser(new User(
Id: response.UserId,
DisplayName: response.DisplayName,
WhichAccountPlatform: WhichAccountPlatform.Guest));
await shogiSocket.OpenAsync(response.OneTimeToken.ToString());
return true;
}
}
else if (platform == WhichAccountPlatform.Microsoft)
{
var state = await authState.GetAuthenticationStateAsync();
if (state.User?.Identity?.Name != null)
{
var response = await shogiApi.GetToken(WhichAccountPlatform.Microsoft);
if (response == null)
{
// Login failed, so reset local storage to avoid putting the user in a broken state.
await localStorage.DeleteAccountPlatform();
return false;
}
var id = state.User.Claims.Single(claim => claim.Type == "oid").Value;
var displayName = state.User.Identity.Name;
await accountState.SetUser(new User(
Id: id,
DisplayName: displayName,
WhichAccountPlatform: WhichAccountPlatform.Microsoft));
await shogiSocket.OpenAsync(response.OneTimeToken.ToString());
return true;
}
}
return false;
}
public async Task LogoutAsync()
{
var platform = await localStorage.GetAccountPlatform();
await localStorage.DeleteAccountPlatform();
await accountState.SetUser(null);
if (platform == WhichAccountPlatform.Guest)
{
await shogiApi.GuestLogout();
}
else if (platform == WhichAccountPlatform.Microsoft)
{
navigation.NavigateToLogout("authentication/logout");
}
else
{
throw new InvalidOperationException("Tried to logout without a valid account platform.");
}
}
}

View File

@@ -1,27 +0,0 @@
using static Shogi.UI.Shared.Events;
namespace Shogi.UI.Pages.Home.Account;
public class AccountState
{
public event AsyncEventHandler<LoginEventArgs>? LoginChangedEvent;
public User? User { get; private set; }
public Task SetUser(User? user)
{
User = user;
return EmitLoginChangedEvent();
}
private async Task EmitLoginChangedEvent()
{
if (LoginChangedEvent is not null)
{
await LoginChangedEvent.Invoke(new LoginEventArgs
{
User = User
});
}
}
}

View File

@@ -1,23 +0,0 @@
using Shogi.UI.Shared;
namespace Shogi.UI.Pages.Home.Account;
public static class LocalStorageExtensions
{
private const string AccountPlatform = "AccountPlatform";
public static Task<WhichAccountPlatform?> GetAccountPlatform(this ILocalStorage self)
{
return self.Get<WhichAccountPlatform>(AccountPlatform).AsTask();
}
public static Task SetAccountPlatform(this ILocalStorage self, WhichAccountPlatform platform)
{
return self.Set(AccountPlatform, platform.ToString()).AsTask();
}
public static Task DeleteAccountPlatform(this ILocalStorage self)
{
return self.Delete(AccountPlatform).AsTask();
}
}

View File

@@ -1,6 +0,0 @@
namespace Shogi.UI.Pages.Home.Account;
public class LoginEventArgs : EventArgs
{
public User? User { get; set; }
}

View File

@@ -1,9 +0,0 @@
namespace Shogi.UI.Pages.Home.Account
{
public readonly record struct User(
string Id,
string DisplayName,
WhichAccountPlatform WhichAccountPlatform)
{
}
}

View File

@@ -1,8 +0,0 @@
namespace Shogi.UI.Pages.Home.Account
{
public enum WhichAccountPlatform
{
Guest,
Microsoft
}
}

View File

@@ -1,26 +0,0 @@
using Microsoft.AspNetCore.Components.WebAssembly.Http;
namespace Shogi.UI.Pages.Home.Api
{
public class CookieCredentialsMessageHandler : DelegatingHandler
{
public CookieCredentialsMessageHandler()
{
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
try
{
return base.SendAsync(request, cancellationToken);
}
catch
{
Console.WriteLine("Catch!");
return base.SendAsync(request, cancellationToken);
}
}
}
}

View File

@@ -1,17 +0,0 @@
using Shogi.Contracts.Api;
using Shogi.Contracts.Types;
using Shogi.UI.Pages.Home.Account;
using System.Net;
namespace Shogi.UI.Pages.Home.Api;
public interface IShogiApi
{
Task<Session?> GetSession(string name);
Task<ReadSessionsPlayerCountResponse?> GetSessionsPlayerCount();
Task<CreateTokenResponse?> GetToken(WhichAccountPlatform whichAccountPlatform);
Task GuestLogout();
Task Move(string sessionName, MovePieceCommand move);
Task<HttpResponseMessage> PatchJoinGame(string name);
Task<HttpStatusCode> PostSession(string name, bool isPrivate);
}

View File

@@ -1,23 +0,0 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
namespace Shogi.UI.Pages.Home.Api
{
public class MsalMessageHandler : AuthorizationMessageHandler
{
public MsalMessageHandler(IAccessTokenProvider provider, NavigationManager navigation) : base(provider, navigation)
{
ConfigureHandler(
authorizedUrls: new[] { "https://api.lucaserver.space/Shogi.Api", "https://localhost:5001" },
scopes: new string[] {
"api://c1e94676-cab0-42ba-8b6c-9532b8486fff/DefaultScope",
//"offline_access",
});
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return base.SendAsync(request, cancellationToken);
}
}
}

View File

@@ -1,106 +0,0 @@
using Shogi.Contracts.Api;
using Shogi.Contracts.Types;
using Shogi.UI.Pages.Home.Account;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace Shogi.UI.Pages.Home.Api
{
public class ShogiApi : IShogiApi
{
public const string GuestClientName = "Guest";
public const string MsalClientName = "Msal";
private readonly JsonSerializerOptions serializerOptions;
private readonly AccountState accountState;
private readonly HttpClient guestHttpClient;
private readonly HttpClient msalHttpClient;
private readonly string baseUrl;
public ShogiApi(IHttpClientFactory clientFactory, AccountState accountState, IConfiguration configuration)
{
this.serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
this.accountState = accountState;
this.guestHttpClient = clientFactory.CreateClient(GuestClientName);
this.msalHttpClient = clientFactory.CreateClient(MsalClientName);
this.baseUrl = configuration["ShogiApiUrl"] ?? throw new InvalidOperationException("Configuration missing.");
this.baseUrl = this.baseUrl.TrimEnd('/');
}
private HttpClient HttpClient => accountState.User?.WhichAccountPlatform switch
{
WhichAccountPlatform.Guest => this.guestHttpClient,
WhichAccountPlatform.Microsoft => this.msalHttpClient,
_ => throw new InvalidOperationException("AccountState.User must not be null during API call.")
};
public async Task GuestLogout()
{
var response = await this.guestHttpClient.PutAsync(RelativeUri("User/GuestLogout"), null);
response.EnsureSuccessStatusCode();
}
public async Task<Session?> GetSession(string name)
{
var response = await HttpClient.GetAsync(RelativeUri($"Sessions/{name}"));
if (response.IsSuccessStatusCode)
{
return (await response.Content.ReadFromJsonAsync<ReadSessionResponse>(serializerOptions))?.Session;
}
return null;
}
public async Task<ReadSessionsPlayerCountResponse?> GetSessionsPlayerCount()
{
var response = await HttpClient.GetAsync(RelativeUri("Sessions/PlayerCount"));
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<ReadSessionsPlayerCountResponse>(serializerOptions);
}
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
? this.msalHttpClient
: this.guestHttpClient;
var response = await httpClient.GetAsync(RelativeUri("User/Token"));
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
if (!string.IsNullOrEmpty(content))
{
return JsonSerializer.Deserialize<CreateTokenResponse>(content, serializerOptions);
}
}
return null;
}
public async Task Move(string sessionName, MovePieceCommand command)
{
await this.HttpClient.PatchAsync(RelativeUri($"Sessions/{sessionName}/Move"), JsonContent.Create(command));
}
public async Task<HttpStatusCode> PostSession(string name, bool isPrivate)
{
var response = await HttpClient.PostAsJsonAsync(RelativeUri("Sessions"), new CreateSessionCommand
{
Name = name,
});
return response.StatusCode;
}
public Task<HttpResponseMessage> PatchJoinGame(string name)
{
return HttpClient.PatchAsync(RelativeUri($"Sessions/{name}/Join"), null);
}
private Uri RelativeUri(string path) => new($"{this.baseUrl}/{path}", UriKind.Absolute);
}
}

View File

@@ -1,6 +0,0 @@
@using Contracts.Types;
<GameBoardPresentation Perspective="WhichPlayer.Player1" />
@code {
}

View File

@@ -1,79 +0,0 @@
@using Shogi.Contracts.Api
@using Shogi.Contracts.Socket;
@using Shogi.Contracts.Types;
@using System.Text.RegularExpressions;
@inject IShogiApi ShogiApi
@inject AccountState Account;
@inject PromotePrompt PromotePrompt;
@inject ShogiSocket ShogiSocket;
@if (session == null)
{
<EmptyGameBoard />
}
else if (isSpectating)
{
<SpectatorGameBoard Session="session" />
}
else
{
<SeatedGameBoard Perspective="perspective" Session="session" />
}
@code {
[Parameter]
public string? SessionName { get; set; }
Session? session;
private WhichPlayer perspective;
private bool isSpectating;
protected override void OnInitialized()
{
base.OnInitialized();
ShogiSocket.OnPlayerMoved += OnPlayerMoved_FetchSession;
ShogiSocket.OnSessionJoined += OnSessionJoined_FetchSession;
}
protected override async Task OnParametersSetAsync()
{
await FetchSession();
}
Task OnSessionJoined_FetchSession(SessionJoinedByPlayerSocketMessage args)
{
if (args.SessionName == SessionName)
{
return FetchSession();
}
return Task.CompletedTask;
}
async Task FetchSession()
{
if (!string.IsNullOrWhiteSpace(SessionName))
{
this.session = await ShogiApi.GetSession(SessionName);
if (this.session != null)
{
var accountId = Account.User?.Id;
this.perspective = accountId == session.Player1.Id ? WhichPlayer.Player1 : WhichPlayer.Player2;
Console.WriteLine(new { this.perspective, accountId });
this.isSpectating = !(accountId == this.session.Player1.Id || accountId == this.session.Player2?.Id);
}
StateHasChanged();
}
}
Task OnPlayerMoved_FetchSession(PlayerHasMovedMessage args)
{
if (args.SessionName == SessionName)
{
return FetchSession();
}
return Task.CompletedTask;
}
}

View File

@@ -1,196 +0,0 @@
@using Shogi.Contracts.Types;
@using System.Text.Json;
@inject PromotePrompt PromotePrompt;
@inject AccountState AccountState;
<article class="game-board">
@if (IsSpectating)
{
<aside class="icons">
<div class="spectating" title="You are spectating.">
<svg width="32" height="32" fill="currentColor">
<use xlink:href="css/bootstrap/bootstrap-icons.svg#camera-reels" />
</svg>
</div>
</aside>
}
<!-- Game board -->
<section class="board" data-perspective="@Perspective">
@for (var rank = 1; rank < 10; rank++)
{
foreach (var file in Files)
{
var position = $"{file}{rank}";
var piece = Session?.BoardState.Board[position];
var isSelected = piece != null && SelectedPosition == position;
<div class="tile" @onclick="OnClickTileInternal(piece, position)"
data-position="@(position)"
data-selected="@(isSelected)"
style="grid-area: @position">
@if (piece != null){
<GamePiece Piece="piece" Perspective="Perspective" />
}
</div>
}
}
<div class="ruler vertical" style="grid-area: rank">
<span>9</span>
<span>8</span>
<span>7</span>
<span>6</span>
<span>5</span>
<span>4</span>
<span>3</span>
<span>2</span>
<span>1</span>
</div>
<div class="ruler" style="grid-area: file">
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
<span>E</span>
<span>F</span>
<span>G</span>
<span>H</span>
<span>I</span>
</div>
<!-- Promote prompt -->
<div class="promote-prompt" data-visible="@PromotePrompt.IsVisible">
<p>Do you wish to promote?</p>
<div>
<button type="button">Yes</button>
<button type="button">No</button>
<button type="button">Cancel</button>
</div>
</div>
</section>
<!-- Side board -->
@if (Session != null)
{
<aside class="side-board">
<div class="player-area">
<div class="hand">
@if (opponentHand.Any())
{
@foreach (var piece in opponentHand)
{
<div class="tile">
<GamePiece Piece="piece" Perspective="Perspective" />
</div>
}
}
</div>
<p class="text-center">Opponent Hand</p>
</div>
<div class="spacer place-self-center text-center">
<p>@opponentName</p>
<p title="It is @(IsMyTurn ? "your" : "their") turn.">
<svg width="32" height="32" fill="currentColor">
@if (IsMyTurn)
{
<use xlink:href="css/bootstrap/bootstrap-icons.svg#chevron-down" />
}
else
{
<use xlink:href="css/bootstrap/bootstrap-icons.svg#chevron-up" />
}
</svg>
</p>
<p>@userName</p>
</div>
<div class="player-area">
@if (Session.Player2 == null && Session.Player1.Id != AccountState.User?.Id)
{
<div class="place-self-center">
<p>Seat is Empty</p>
<button @onclick="OnClickJoinGameInternal">Join Game</button>
</div>
}
else
{
<p class="text-center">Hand</p>
<div class="hand">
@if (userHand.Any())
{
@foreach (var piece in userHand)
{
<div @onclick="OnClickHandInternal(piece)"
class="tile"
data-selected="@(piece.WhichPiece == SelectedPieceFromHand)">
<GamePiece Piece="piece" Perspective="Perspective" />
</div>
}
}
</div>
}
</div>
</aside>
}
</article>
@code {
static readonly string[] Files = new[] { "A", "B", "C", "D", "E", "F", "G", "H", "I" };
/// <summary>
/// When true, an icon is displayed indicating that the user is spectating.
/// </summary>
[Parameter] public bool IsSpectating { get; set; } = false;
[Parameter] public WhichPlayer Perspective { get; set; }
[Parameter] public Session? Session { get; set; }
[Parameter] public string? SelectedPosition { get; set; }
[Parameter] public WhichPiece? SelectedPieceFromHand { get; set; }
// TODO: Exchange these OnClick actions for events like "SelectionChangedEvent" and "MoveFromBoardEvent" and "MoveFromHandEvent".
[Parameter] public Func<Piece?, string, Task>? OnClickTile { get; set; }
[Parameter] public Func<Piece, Task>? OnClickHand { get; set; }
[Parameter] public Func<Task>? OnClickJoinGame { get; set; }
[Parameter] public bool IsMyTurn { get; set; }
private IReadOnlyCollection<Piece> opponentHand;
private IReadOnlyCollection<Piece> userHand;
private string? userName;
private string? opponentName;
public GameBoardPresentation()
{
opponentHand = Array.Empty<Piece>();
userHand = Array.Empty<Piece>();
userName = string.Empty;
opponentName = string.Empty;
}
protected override void OnParametersSet()
{
base.OnParametersSet();
if (Session == null)
{
opponentHand = Array.Empty<Piece>();
userHand = Array.Empty<Piece>();
userName = string.Empty;
opponentName = string.Empty;
}
else
{
Console.WriteLine(JsonSerializer.Serialize(new { this.Session.Player1, this.Session.Player2, Perspective, this.Session.SessionName }));
opponentHand = Perspective == WhichPlayer.Player1
? this.Session.BoardState.Player2Hand
: this.Session.BoardState.Player1Hand;
userHand = Perspective == WhichPlayer.Player1
? this.Session.BoardState.Player1Hand
: this.Session.BoardState.Player2Hand;
userName = Perspective == WhichPlayer.Player1
? this.Session.Player1.Name
: this.Session.Player2?.Name ?? "Empty Seat";
opponentName = Perspective == WhichPlayer.Player1
? this.Session.Player2?.Name ?? "Empty Seat"
: this.Session.Player1.Name;
}
}
private Action OnClickTileInternal(Piece? piece, string position) => () => OnClickTile?.Invoke(piece, position);
private Action OnClickHandInternal(Piece piece) => () => OnClickHand?.Invoke(piece);
private void OnClickJoinGameInternal() => OnClickJoinGame?.Invoke();
}

View File

@@ -1,143 +0,0 @@
.game-board {
display: grid;
/*grid-template-areas: "board side-board icons";
grid-template-columns: 1fr minmax(9rem, 15rem) 3rem;*/
grid-template-areas: "board";
grid-template-columns: 1fr;
place-content: center;
padding: 1rem;
gap: 0.5rem;
background-color: #444;
position: relative; /* For absolute positioned children. */
}
.board {
grid-area: board;
}
.side-board {
grid-area: side-board;
}
.icons {
grid-area: icons;
}
.board {
position: relative;
display: grid;
grid-template-areas:
"rank A9 B9 C9 D9 E9 F9 G9 H9 I9"
"rank A8 B8 C8 D8 E8 F8 G8 H8 I8"
"rank A7 B7 C7 D7 E7 F7 G7 H7 I7"
"rank A6 B6 C6 D6 E6 F6 G6 H6 I6"
"rank A5 B5 C5 D5 E5 F5 G5 H5 I5"
"rank A4 B4 C4 D4 E4 F4 G4 H4 I4"
"rank A3 B3 C3 D3 E3 F3 G3 H3 I3"
"rank A2 B2 C2 D2 E2 F2 G2 H2 I2"
"rank A1 B1 C1 D1 E1 F1 G1 H1 I1"
". file file file file file file file file file";
grid-template-columns: auto repeat(9, 1fr);
grid-template-rows: repeat(9, 1fr) auto;
gap: 3px;
aspect-ratio: 0.9167;
max-height: calc(100vh - 2rem);
}
.board[data-perspective="Player2"] {
grid-template-areas:
"file file file file file file file file file ."
"I1 H1 G1 F1 E1 D1 C1 B1 A1 rank"
"I2 H2 G2 F2 E2 D2 C2 B2 A2 rank"
"I3 H3 G3 F3 E3 D3 C3 B3 A3 rank"
"I4 H4 G4 F4 E4 D4 C4 B4 A4 rank"
"I5 H5 G5 F5 E5 D5 C5 B5 A5 rank"
"I6 H6 G6 F6 E6 D6 C6 B6 A6 rank"
"I7 H7 G7 F7 E7 D7 C7 B7 A7 rank"
"I8 H8 G8 F8 E8 D8 C8 B8 A8 rank"
"I9 H9 G9 F9 E9 D9 C9 B9 A9 rank";
grid-template-columns: repeat(9, minmax(0, 1fr)) auto;
grid-template-rows: auto repeat(9, minmax(0, 1fr));
}
.tile {
display: grid;
place-content: center;
transition: filter linear 0.25s;
aspect-ratio: 0.9167;
}
.board .tile {
background-color: beige;
}
.tile[data-selected] {
filter: invert(0.8);
}
.ruler {
color: beige;
display: flex;
flex-direction: row;
justify-content: space-around;
}
.ruler.vertical {
flex-direction: column;
}
.board[data-perspective="Player2"] .ruler {
flex-direction: row-reverse;
}
.board[data-perspective="Player2"] .ruler.vertical {
flex-direction: column-reverse;
}
.side-board {
display: flex;
flex-direction: column;
place-content: space-between;
padding: 1rem;
background-color: var(--contrast-color);
}
.side-board .player-area {
display: grid;
place-items: stretch;
}
.side-board .hand {
display: grid;
border: 1px solid #ccc;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: 4rem;
place-items: center start;
padding: 0.5rem;
}
.side-board .hand .tile {
max-height: 100%; /* I have no idea why I need to set this here to prevent a height blowout. */
background-color: var(--secondary-color);
}
.promote-prompt {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 2px solid #444;
background-color: #eaeaea;
padding: 1rem;
box-shadow: 1px 1px 1px #444;
text-align: center;
}
.promote-prompt[data-visible="true"] {
display: block;
}
.spectating {
color: var(--contrast-color)
}

View File

@@ -1,121 +0,0 @@
@using Shogi.Contracts.Api;
@using Shogi.Contracts.Types;
@using System.Text.RegularExpressions;
@using System.Net;
@inject PromotePrompt PromotePrompt;
@inject IShogiApi ShogiApi;
<GameBoardPresentation Session="Session"
Perspective="Perspective"
OnClickHand="OnClickHand"
OnClickTile="OnClickTile"
SelectedPosition="@selectedBoardPosition"
SelectedPieceFromHand="@selectedPieceFromHand"
IsMyTurn="IsMyTurn" />
@code {
[Parameter, EditorRequired]
public WhichPlayer Perspective { get; set; }
[Parameter, EditorRequired]
public Session Session { get; set; }
private bool IsMyTurn => Session?.BoardState.WhoseTurn == Perspective;
private string? selectedBoardPosition;
private WhichPiece? selectedPieceFromHand;
protected override void OnParametersSet()
{
base.OnParametersSet();
selectedBoardPosition = null;
selectedPieceFromHand = null;
if (Session == null)
{
throw new ArgumentException($"{nameof(Session)} cannot be null.", nameof(Session));
}
}
bool ShouldPromptForPromotion(string position)
{
if (Perspective == WhichPlayer.Player1 && Regex.IsMatch(position, ".[7-9]"))
{
return true;
}
if (Perspective == WhichPlayer.Player2 && Regex.IsMatch(position, ".[1-3]"))
{
return true;
}
return false;
}
async Task OnClickTile(Piece? pieceAtPosition, string position)
{
if (!IsMyTurn) return;
if (selectedBoardPosition == position)
{
// Deselect the selected position.
selectedBoardPosition = null;
StateHasChanged();
return;
}
if (selectedBoardPosition == null && pieceAtPosition?.Owner == Perspective)
{
// Select an owned piece.
Console.WriteLine("Selecting piece owned by {0} while I am perspective {1}", pieceAtPosition?.Owner, Perspective);
selectedBoardPosition = position;
// Prevent selecting pieces from the hand and board at the same time.
selectedPieceFromHand = null;
StateHasChanged();
return;
}
if (selectedPieceFromHand is not null)
{
if (pieceAtPosition is null)
{
// Placing a piece from the hand to an empty space.
await ShogiApi.Move(
Session.SessionName,
new MovePieceCommand(selectedPieceFromHand.Value, position));
}
StateHasChanged();
return;
}
if (selectedBoardPosition != null)
{
if (pieceAtPosition == null || pieceAtPosition?.Owner != Perspective)
{
// Moving to an empty space or capturing an opponent's piece.
if (ShouldPromptForPromotion(position) || ShouldPromptForPromotion(selectedBoardPosition))
{
PromotePrompt.Show(
Session.SessionName,
new MovePieceCommand(selectedBoardPosition, position, false));
}
else
{
await ShogiApi.Move(Session.SessionName, new MovePieceCommand(selectedBoardPosition, position, false));
}
StateHasChanged();
return;
}
}
}
async Task OnClickHand(Piece piece)
{
if (!IsMyTurn) return;
// Prevent selecting from both the hand and the board.
selectedBoardPosition = null;
selectedPieceFromHand = piece.WhichPiece == selectedPieceFromHand
// Deselecting the already-selected piece
? selectedPieceFromHand = null
: selectedPieceFromHand = piece.WhichPiece;
StateHasChanged();
}
}

View File

@@ -1,27 +0,0 @@
@using Contracts.Types;
@using System.Net;
@inject IShogiApi ShogiApi;
<GameBoardPresentation IsSpectating="true"
Perspective="WhichPlayer.Player2"
Session="Session"
OnClickJoinGame="OnClickJoinGame" />
@code {
[Parameter] public Session Session { get; set; }
protected override void OnParametersSet()
{
base.OnParametersSet();
if (Session == null)
{
throw new ArgumentException($"{nameof(Session)} cannot be null.", nameof(Session));
}
}
async Task OnClickJoinGame()
{
var response = await ShogiApi.PatchJoinGame(Session.SessionName);
response.EnsureSuccessStatusCode();
}
}

View File

@@ -1,160 +0,0 @@
@implements IDisposable;
@using Shogi.Contracts.Socket;
@using Shogi.Contracts.Types;
@using System.ComponentModel.DataAnnotations;
@using System.Net;
@using System.Text.Json;
@inject IShogiApi ShogiApi;
@inject ShogiSocket ShogiSocket;
@inject AccountState Account;
<section class="game-browser">
<ul class="nav nav-tabs">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#search-pane">Search</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#create-pane">Create</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="search-pane">
<h3>Games you&apos;re seated at</h3>
<div class="list-group">
@if (!joinedSessions.Any())
{
<p>You have not joined any games.</p>
}
@foreach (var session in joinedSessions)
{
<button class="list-group-item list-group-item-action @ActiveCss(session)" @onclick="() => OnClickSession(session)">
<span>@session.Name</span>
<span>(@session.PlayerCount/2)</span>
</button>
}
</div>
<h3>Other games</h3>
<div class="list-group">
@if (!otherSessions.Any())
{
<p>You have not joined any games.</p>
}
@foreach (var session in otherSessions)
{
<button class="list-group-item list-group-item-action @ActiveCss(session)" @onclick="() => OnClickSession(session)">
<span>@session.Name</span>
<span>(@session.PlayerCount/2)</span>
</button>
}
</div>
</div>
<div class="tab-pane fade" id="create-pane">
<EditForm Model="createForm" OnValidSubmit="async () => await CreateSession()">
<DataAnnotationsValidator />
<h3>Start a new session</h3>
<div class="form-floating mb-3">
<InputText type="text" class="form-control" id="session-name" placeholder="Session name" @bind-Value="createForm.Name" />
<label for="session-name">Session name</label>
</div>
<div class="flex-between mb-3">
<div class="form-check">
<InputCheckbox class="form-check-input" role="switch" id="session-privacy" @bind-Value="createForm.IsPrivate" />
<label class="form-check-label" for="session-privacy">Private?</label>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</div>
@if (createSessionStatusCode == HttpStatusCode.Created)
{
<div class="alert alert-success" role="alert">
Session started. View it in the search tab.
</div>
}
else if (createSessionStatusCode == HttpStatusCode.Conflict)
{
<div class="alert alert-warning" role="alert">
The name you chose is taken; choose another.
</div>
}
</EditForm>
</div>
</div>
</section>
@code {
[Parameter]
public Action<SessionMetadata>? ActiveSessionChanged { get; set; }
private CreateForm createForm = new();
private SessionMetadata[] joinedSessions = Array.Empty<SessionMetadata>();
private SessionMetadata[] otherSessions = Array.Empty<SessionMetadata>();
private SessionMetadata? activeSession;
private HttpStatusCode? createSessionStatusCode;
protected override void OnInitialized()
{
base.OnInitialized();
ShogiSocket.OnSessionCreated += FetchSessions;
ShogiSocket.OnSessionJoined += OnSessionJoined_FetchSessions;
Account.LoginChangedEvent += LoginChangedEvent_FetchSessions;
}
string ActiveCss(SessionMetadata s) => s == activeSession ? "active" : string.Empty;
void OnClickSession(SessionMetadata s)
{
activeSession = s;
ActiveSessionChanged?.Invoke(s);
}
Task OnSessionJoined_FetchSessions(SessionJoinedByPlayerSocketMessage args) => FetchSessions();
Task LoginChangedEvent_FetchSessions(LoginEventArgs args)
{
if (args.User == null)
{
joinedSessions = Array.Empty<SessionMetadata>();
otherSessions = Array.Empty<SessionMetadata>();
StateHasChanged();
}
else
{
return FetchSessions();
}
return Task.CompletedTask;
}
async Task FetchSessions()
{
var sessions = await ShogiApi.GetSessionsPlayerCount();
if (sessions != null)
{
this.joinedSessions = sessions.PlayerHasJoinedSessions.ToArray();
this.otherSessions = sessions.AllOtherSessions.ToArray();
StateHasChanged();
}
}
async Task CreateSession()
{
createSessionStatusCode = await ShogiApi.PostSession(createForm.Name, createForm.IsPrivate);
}
public void Dispose()
{
ShogiSocket.OnSessionCreated -= FetchSessions;
ShogiSocket.OnSessionJoined -= OnSessionJoined_FetchSessions;
Account.LoginChangedEvent -= LoginChangedEvent_FetchSessions;
}
private class CreateForm
{
[Required]
public string Name { get; set; } = string.Empty;
public bool IsPrivate { get; set; }
}
}

View File

@@ -1,13 +0,0 @@
.game-browser {
padding: 0.5rem;
background-color: var(--contrast-color);
}
#search-pane button.list-group-item {
display: grid;
grid-template-columns: 1fr auto;
}
#search-pane button.list-group-item.active {
background-color: #444;
border-color: #666;
}

View File

@@ -1,44 +0,0 @@
@using Shogi.Contracts.Types
<div class="game-piece" data-upsidedown="@(Piece?.Owner != Perspective)" data-owner="@Piece?.Owner.ToString()">
@switch (Piece?.WhichPiece)
{
case WhichPiece.Bishop:
<Bishop IsPromoted="@IsPromoted" />
break;
case WhichPiece.GoldGeneral:
<GoldGeneral />
break;
case WhichPiece.King:
<ChallengingKing />
break;
case WhichPiece.Knight:
<Knight IsPromoted="@IsPromoted" />
break;
case WhichPiece.Lance:
<Lance IsPromoted="@IsPromoted" />
break;
case WhichPiece.Pawn:
<Pawn IsPromoted="@IsPromoted" />
break;
case WhichPiece.Rook:
<Rook IsPromoted="@IsPromoted" />
break;
case WhichPiece.SilverGeneral:
<SilverGeneral IsPromoted="@IsPromoted" />
break;
default:
@*render nothing*@
break;
}
</div>
@code {
[Parameter]
public Contracts.Types.Piece? Piece { get; set; }
[Parameter]
public WhichPlayer Perspective { get; set; }
private bool IsPromoted => Piece != null && Piece.IsPromoted;
}

View File

@@ -1,12 +0,0 @@
::deep svg {
max-width: 100%;
max-height: 100%;
}
[data-upsidedown] {
transform: rotateZ(180deg);
}
.game-piece {
overflow: hidden; /* Because SVGs have weird sizes. */
}

View File

@@ -1,61 +0,0 @@
@page "/"
@using Shogi.Contracts.Types
@using System.Net.WebSockets
@using System.Text
@inject AccountManager AccountManager
@inject AccountState Account
<main class="shogi">
@if (welcomeModalIsVisible)
{
<LoginModal />
}
<aside class="sidebar">
<PageHeader />
<GameBrowser ActiveSessionChanged="OnChangeSession" />
</aside>
@if (Account.User == null || activeSessionName == null)
{
<EmptyGameBoard />
}
else
{
<GameBoard SessionName="@activeSessionName" />
}
</main>
@code {
private bool welcomeModalIsVisible;
private string activeSessionName;
private ClientWebSocket socket;
public Home()
{
welcomeModalIsVisible = false;
activeSessionName = string.Empty;
socket = new ClientWebSocket();
}
protected override async Task OnInitializedAsync()
{
Account.LoginChangedEvent += OnLoginChanged;
var success = await AccountManager.TryLoginSilentAsync();
if (!success)
{
welcomeModalIsVisible = true;
}
}
private Task OnLoginChanged(LoginEventArgs args)
{
welcomeModalIsVisible = args.User == null;
StateHasChanged();
return Task.CompletedTask;
}
private void OnChangeSession(SessionMetadata s)
{
activeSessionName = s.Name;
StateHasChanged();
}
}

View File

@@ -1,23 +0,0 @@
.shogi {
display: grid;
grid-template-areas:
"sidebar board";
grid-template-columns: clamp(20rem, 20vw, 25rem) 1fr;
position: relative; /* For absolute positioned children. */
background-color: var(--primary-color);
}
.shogi > .sidebar {
grid-area: sidebar;
display: grid;
grid-template-rows: auto 1fr;
gap: 3px;
}
.shogi > .sidebar.collapsed {
width: 5rem;
}
.shogi > ::deep .game-board {
grid-area: board;
}

View File

@@ -1,52 +0,0 @@
@inject AccountManager Account
@inject AccountState AccountState
<div class="my-modal-background">
<div class="my-modal">
@if (guestAccountDescriptionIsVisible)
{
<h1>What&apos;s the difference?</h1>
<p>
Guest accounts are session based, meaning that the account lives exclusively within the device and browser you play on as a guest.
This is the only difference between guest and email accounts.
</p>
<div class="alert alert-warning">
Deleting your device's browser storage for this site also deletes your guest account. This data is how you are remembered between sessions.
</div>
<button class="btn btn-link smaller" @onclick="HideGuestAccountDescription">Take me back</button>
}
else
{
<h1>Welcome to Shogi!</h1>
<div>
<p>How would you like to proceed?</p>
<p>
<button @onclick="async () => await Account.LoginWithMicrosoftAccount()">Log in</button>
<button @onclick="async () => await Account.LoginWithGuestAccount()">Proceed as Guest</button>
@if (AccountState.User != null)
{
/* This is an escape hatch in case user login fails in certain ways. */
<button @onclick="Account.LogoutAsync">Logout</button>
}
</p>
</div>
<p>
<button class="btn btn-link smaller" @onclick="ShowGuestAccountDescription">What&apos;s the difference?</button>
</p>
}
</div>
</div>
@code {
bool guestAccountDescriptionIsVisible = false;
void ShowGuestAccountDescription()
{
guestAccountDescriptionIsVisible = true;
}
void HideGuestAccountDescription()
{
guestAccountDescriptionIsVisible = false;
}
}

View File

@@ -1,21 +0,0 @@
.my-modal-background {
display: grid;
place-items: center;
position: fixed;
background-color: rgba(0,0,0,0.4);
inset: 0;
z-index: 900;
}
.my-modal {
text-align: center;
background-color: var(--contrast-color);
padding: 1rem;
max-width: 40rem;
}
.account-description {
display: grid;
grid-template-columns: 1fr max-content max-content;
column-gap: 1.5rem;
}

View File

@@ -1,35 +0,0 @@
@inject AccountState Account
@inject AccountManager AccountManager
<div class="pageHeader">
<h1>Shogi</h1>
@if (user == null)
{
<button type="button" class="logout" @onclick="AccountManager.LogoutAsync">Logout</button>
}
else
{
<div class="user">
<div>@user.Value.DisplayName</div>
<button type="button" class="logout" @onclick="AccountManager.LogoutAsync">Logout</button>
</div>
}
</div>
@code {
private User? user;
protected override void OnInitialized()
{
Account.LoginChangedEvent += OnLoginChange;
}
private Task OnLoginChange(LoginEventArgs args)
{
if (args == null)
throw new ArgumentException(nameof(args));
user = args.User;
StateHasChanged();
return Task.CompletedTask;
}
}

View File

@@ -1,20 +0,0 @@
.pageHeader {
display: grid;
grid-template-columns: 1fr max-content;
place-items: center stretch;
place-content: stretch;
padding: 0.5rem;
background-color: var(--contrast-color);
}
.pageHeader h1 {
place-self: baseline start;
}
.pageHeader button.logout {
display: block;
width: 100%;
}
.pageHeader .user {
text-align: right;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,58 +0,0 @@
using Shogi.Contracts.Api;
using Shogi.UI.Pages.Home.Api;
namespace Shogi.UI.Pages.Home;
public class PromotePrompt
{
private readonly IShogiApi shogiApi;
private string? sessionName;
private MovePieceCommand? command;
public PromotePrompt(IShogiApi shogiApi)
{
this.shogiApi = shogiApi;
IsVisible = false;
OnClickCancel = Hide;
}
public bool IsVisible { get; private set; }
public Action OnClickCancel;
public Func<Task>? OnClickNo;
public Func<Task>? OnClickYes;
public void Show(string sessionName, MovePieceCommand command)
{
this.sessionName = sessionName;
this.command = command;
IsVisible = true;
OnClickNo = Move;
OnClickYes = MoveAndPromote;
}
public void Hide()
{
IsVisible = false;
OnClickNo = null;
OnClickYes = null;
}
private Task Move()
{
if (command != null && sessionName != null)
{
command.IsPromotion = false;
return shogiApi.Move(sessionName, command);
}
return Task.CompletedTask;
}
private Task MoveAndPromote()
{
if (command != null && sessionName != null)
{
command.IsPromotion = true;
return shogiApi.Move(sessionName, command);
}
return Task.CompletedTask;
}
}