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

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

View File

@@ -0,0 +1,76 @@
@using Shogi.Contracts.Api
@using Shogi.Contracts.Types
@using System.Text.RegularExpressions
@using System.Security.Claims
@implements IDisposable
@inject ShogiApi ShogiApi
@inject PromotePrompt PromotePrompt
@inject GameHubNode hubNode
@inject NavigationManager navigator
@if (session == null)
{
<EmptyGameBoard />
}
else if (isSpectating)
{
<SpectatorGameBoard Session="session" />
}
else
{
<SeatedGameBoard Perspective="perspective" Session="session" />
}
@code {
[CascadingParameter]
private Task<AuthenticationState> authenticationState { get; set; }
[Parameter]
[EditorRequired]
public string SessionId { get; set; } = string.Empty;
Session? session;
private WhichPlayer perspective;
private bool isSpectating;
private List<IDisposable> disposables = new List<IDisposable>(2);
protected override void OnInitialized()
{
navigator.RegisterLocationChangingHandler((a) => new ValueTask(hubNode.Unsubscribe(SessionId)));
disposables.Add(hubNode.OnSessionJoined(async () => await FetchSession()));
disposables.Add(hubNode.OnPieceMoved(async () => await FetchSession()));
}
protected override async Task OnParametersSetAsync()
{
await hubNode.Subscribe(SessionId);
await FetchSession();
}
async Task FetchSession()
{
if (!string.IsNullOrWhiteSpace(SessionId))
{
this.session = await ShogiApi.GetSession(SessionId);
if (this.session != null)
{
var state = await authenticationState;
var accountId = state.User.Claims.First(c => c.Type == ClaimTypes.Name).Value;
this.perspective = accountId == session.Player1 ? WhichPlayer.Player1 : WhichPlayer.Player2;
this.isSpectating = !(accountId == this.session.Player1 || accountId == this.session.Player2);
}
StateHasChanged();
}
}
public void Dispose()
{
foreach (var d in disposables)
{
d.Dispose();
}
disposables.Clear();
}
}

View File

@@ -0,0 +1,183 @@
@using Shogi.Contracts.Types;
@using System.Text.Json;
@inject PromotePrompt PromotePrompt;
<article class="game-board">
@if (IsSpectating)
{
<aside class="icons">
<div title="You are spectating.">
<span>Camera icon</span>
</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 PrimaryTheme ThemeVariant--Contrast">
<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="place-self-center">
<PlayerName Name="@opponentName" IsTurn="!IsMyTurn" />
<hr />
<PlayerName Name="@userName" IsTurn="IsMyTurn" />
</div>
<div class="player-area">
@if (this.OnClickJoinGame != null && string.IsNullOrEmpty(Session.Player2) && !string.IsNullOrEmpty(Session.Player1))
{
<div class="place-self-center">
<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
{
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
: this.Session.Player2 ?? "Empty Seat";
opponentName = Perspective == WhichPlayer.Player1
? this.Session.Player2 ?? "Empty Seat"
: this.Session.Player1;
}
}
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

@@ -0,0 +1,128 @@
.game-board {
--ratio: 0.9;
display: grid;
grid-template-areas: "board side-board icons";
grid-template-columns: auto minmax(10rem, 30rem) 1fr;
background-color: #444;
position: relative; /* For absolute positioned promote prompt. */
}
.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;
padding: 1rem;
background-color: #444444;
height: calc(100vmin - 2rem);
aspect-ratio: var(--ratio);
}
.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));
}
.board .tile {
display: grid;
place-content: center;
aspect-ratio: var(--ratio);
background-color: beige;
transition: filter linear 0.25s;
}
.board .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;
}
.side-board .player-area {
display: grid;
place-items: stretch;
}
.side-board .hand {
display: grid;
border: 1px solid #ccc;
grid-template-columns: repeat(auto-fill, 3rem);
grid-template-rows: 3rem;
place-items: center start;
padding: 0.5rem;
}
.promote-prompt {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 2px solid #444;
padding: 1rem;
box-shadow: 1px 1px 1px #444;
text-align: center;
}
.promote-prompt[data-visible="true"] {
display: block;
}

View File

@@ -0,0 +1,21 @@
<p style="margin: 0">
@if (IsTurn)
{
<span>*&nbsp;</span>
}
@if (string.IsNullOrEmpty(Name))
{
<span>Empty Seat</span>
}
else
{
<span>@Name</span>
}
</p>
@code {
[Parameter][EditorRequired] public bool IsTurn { get; set; }
[Parameter][EditorRequired] public string Name { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,128 @@
@using Shogi.Contracts.Api;
@using Shogi.Contracts.Types;
@using System.Text.RegularExpressions;
@using System.Net;
@inject PromotePrompt PromotePrompt;
@inject ShogiApi 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; } = default!;
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.
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.
var success = await ShogiApi.Move(
Session.SessionId.ToString(),
new MovePieceCommand(selectedPieceFromHand.Value, position));
if (!success)
{
selectedPieceFromHand = null;
}
}
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.SessionId.ToString(),
new MovePieceCommand(selectedBoardPosition, position, false));
}
else
{
var success = await ShogiApi.Move(Session.SessionId.ToString(), new MovePieceCommand(selectedBoardPosition, position, false));
if (!success)
{
selectedBoardPosition = null;
}
}
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

@@ -0,0 +1,29 @@
@using Contracts.Types
@using System.Net
@inject ShogiApi ShogiApi
<GameBoardPresentation IsSpectating="true"
Perspective="WhichPlayer.Player2"
Session="Session"
OnClickJoinGame="OnClickJoinGame" />
@code {
[Parameter]
[EditorRequired]
public Session Session { get; set; } = default!;
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.SessionId.ToString());
response.EnsureSuccessStatusCode();
}
}