2 Commits

Author SHA1 Message Date
460dfd608e checkpoint 2024-11-16 12:37:56 -06:00
13e79eb490 Saving snapshots 2024-11-09 13:35:39 -06:00
247 changed files with 3987 additions and 3944 deletions

1
.gitignore vendored
View File

@@ -55,4 +55,3 @@ obj
*.user
/Shogi.Database/Shogi.Database.dbmdl
/Shogi.Database/Shogi.Database.jfm
/Shogi/appsettings.Development.json

View File

@@ -1,120 +0,0 @@
//using System.Drawing;
//using System.Numerics;
//namespace BoardRules;
//public static class PieceMoves
//{
// public static readonly ICollection<Vector2> GoldGeneralMoves =
// [
// new(-1, 1),
// new(0, 1),
// new(1, 1),
// new(-1, 0),
// new(1, 0),
// new(0, -1)
// ];
// public static readonly ICollection<Vector2> PawnMoves = [new(0, 1)];
// public static readonly ICollection<Vector2> KnightMoves = [new(-1, 2), new(1, 2)];
// public static readonly ICollection<Vector2> BishopMoves =
// [
// new(float.NegativeInfinity, float.NegativeInfinity),
// new(float.NegativeInfinity, float.PositiveInfinity),
// new(float.PositiveInfinity, float.PositiveInfinity),
// new(float.PositiveInfinity, float.NegativeInfinity),
// ];
//}
//public class BoardRules
//{
// private readonly Dictionary<string, IPiece> pieces = [];
// public BoardRules WithSize(int width, int height)
// {
// this.BoardSize = new Size(width, height);
// return this;
// }
// public Size BoardSize { get; private set; }
// public BoardRules AddPiece(IPiece piece)
// {
// pieces.Add(piece.Name, piece);
// return this;
// }
//}
//public class BoardPieceRules(BoardRules rules, IPiece piece)
//{
// public IPiece Piece { get; } = piece;
// public BoardRules WithStartingPositions(ICollection<Vector2> positions)
// {
// // Validate positions against board size
// foreach (var pos in positions)
// {
// if (pos.X < 0 || pos.Y < 0 || pos.X >= rules.BoardSize.Width || pos.Y >= rules.BoardSize.Height)
// {
// throw new ArgumentOutOfRangeException(nameof(positions), $"Position {pos} is out of bounds for board size {rules.BoardSize}.");
// }
// }
// // Assuming piece has a way to set starting positions, which it currently does not.
// // This is just a placeholder to show intent.
// // piece.SetStartingPositions(positions);
// return rules;
// }
//}
//public interface IPiece
//{
// public string Name { get; }
// public ICollection<Vector2> MoveSet { get; }
// public ICollection<Vector2> PromotedMoveSet { get; }
// /// <summary>
// /// The starting positions for this type of piece on the board. There could be one or many.
// /// </summary>
// public ICollection<Vector2> StartingPositions { get; }
//}
//public class GoldGeneral : IPiece
//{
// public string Name => nameof(GoldGeneral);
// public ICollection<Vector2> MoveSet => PieceMoves.GoldGeneralMoves;
// public ICollection<Vector2> PromotedMoveSet => PieceMoves.GoldGeneralMoves;
// public ICollection<Vector2> StartingPositions => [new(3, 0), new(5, 0), new(4, 1)];
//}
//public class Pawn : IPiece
//{
// public string Name => nameof(Pawn);
// public ICollection<Vector2> MoveSet => PieceMoves.PawnMoves;
// public ICollection<Vector2> PromotedMoveSet => PieceMoves.GoldGeneralMoves;
//}
//public class Knight : IPiece
//{
// public string Name => nameof(Knight);
// public ICollection<Vector2> MoveSet => PieceMoves.KnightMoves;
// public ICollection<Vector2> PromotedMoveSet => PieceMoves.GoldGeneralMoves;
//}
//public class Bishop : IPiece
//{
// public string Name => nameof(Bishop);
// public ICollection<Vector2> MoveSet => PieceMoves.BishopMoves;
// public ICollection<Vector2> PromotedMoveSet => PieceMoves.BishopMoves;
//}
//public class Luke
//{
// public void Yep()
// {
// var board = new BoardRules()
// .WithSize(9, 9)
// .AddPiece(new Pawn())
// }
//}

View File

@@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -3,45 +3,20 @@ A web application for playing the Shogi boardgame with others, developed as a ho
The application uses sockets to allow players to enjoy sessions in real time.
### Technologies used
A Blazor Web App which uses Sql Server for presistent storage and Identity EF Core for account management.
A Blazor UI backed by an Asp.net Core API service which uses Sql Server for presistent storage.
### Known Issues
* The app is intended to support logging in via Microsoft accounts or browser-session (Guest) accounts, but currently Microsoft login does not work.
* The workaround is to use the guest login.
* On first load of the UI, guest account login will fail.
* The workaround is to refresh the page and try again. This issue only happens on first load.
### Roadmap of features remaining
The app is not yet finished, though much of the functionality exists. Here is a list of what remains.
* Placing pieces from the hand onto the board.
* Checkmate experience
* Checkmate experience in UI
* Preventing the rarely invoked rule where check-mate cannot be gained by placing a pawn from the hand.
* Retaining an archive of games played and move history of each game.
* Adaptive UI layout for varying viewport (screen) sizes.
### Database Setup
If you don't have them, install the `Data storage and processing` tools through Visual Studio Installer. This gives you a local MSSQL database for you to develop with, which is nice so you aren't touching live data while working.
After that, you need to set up the database. The database has two sources of table structure.
#### 1. Shogi.Database project
This project contains the table structure for the game.
1. Build the Shogi.Database project.
1. Publish the Shogi.Database project (right click in Solution Explorer).
* If you're prompted for a connection string, use the one from `Shogi/appsettings.json`.
#### 2. EntityFramework via AspNetCore.Identity
This solution uses the `Microsoft.AspNetCore.Identity.EntityFrameworkCore` package to offer authentication and authorization. This uses Entity Framework, which comes with tools to setup our database with the necessary table structure for auth.
1. Install the Entity Framework dotnet tools that come with the project. Via Powershell run this command:
```
dotnet tool restore
```
1. Run the database migrations and fill out table structure for auth. Run this command from the MUD.Api project directory:
```
cd /path/to/solution/Shogi
dotnet ef database update
```
After this, you should be ready to create some test accounts for local development.
### Creating Test Accounts
To create test accounts for local development, make sure to build in DEBUG mode and then use the `/debug/create-test-accounts` endpoint
of the API. This will create two accounts. If those accounts already exist, they'll be deleted (along with all game session data associated) and recreated.

View File

@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.2",
"version": "6.0.5",
"commands": [
"dotnet-ef"
]

View File

@@ -1,4 +1,4 @@
namespace Shogi;
namespace Shogi.Api;
public class ApiKeys
{

View File

@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.SignalR;
namespace Shogi.BackEnd.Application;
namespace Shogi.Api.Application;
/// <summary>
/// Used to receive signals from connected clients.

View File

@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.SignalR;
namespace Shogi.BackEnd.Application;
namespace Shogi.Api.Application;
/// <summary>
/// Used to send signals to connected clients.

View File

@@ -1,14 +1,15 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Shogi.BackEnd.Controllers;
using Shogi.BackEnd.Extensions;
using Shogi.BackEnd.Identity;
using Shogi.BackEnd.Repositories;
using Shogi.BackEnd.Repositories.Dto;
using Shogi.BackEnd.Domains.Aggregates;
using Shogi.Api.Controllers;
using Shogi.Api.Extensions;
using Shogi.Api.Identity;
using Shogi.Api.Repositories;
using Shogi.Api.Repositories.Dto;
using Shogi.Contracts.Api.Commands;
using Shogi.Domain.Aggregates;
using System.Data.SqlClient;
namespace Shogi.BackEnd.Application;
namespace Shogi.Api.Application;
public class ShogiApplication(
QueryRepository queryRepository,
@@ -71,10 +72,10 @@ public class ShogiApplication(
return session;
}
public async Task<IActionResult> MovePiece(string playerId, string sessionId, Types.MovePieceCommand command)
public async Task<IActionResult> MovePiece(string playerId, string sessionId, MovePieceCommand command)
{
var session = await this.ReadSession(sessionId);
if (session is null)
if (session == null)
{
return new NotFoundResult();
}
@@ -91,6 +92,7 @@ public class ShogiApplication(
if (moveResult.IsSuccess)
{
await sessionRepository.CreateMove(sessionId, command);
await sessionRepository.CreateState(session);
await gameHubContext.Emit_PieceMoved(sessionId);
return new NoContentResult();
}
@@ -104,7 +106,7 @@ public class ShogiApplication(
public async Task<IActionResult> JoinSession(string sessionId, string player2Id)
{
var session = await this.ReadSession(sessionId);
if (session is null) return new NotFoundResult();
if (session == null) return new NotFoundResult();
if (string.IsNullOrEmpty(session.Player2))
{
@@ -127,4 +129,38 @@ public class ShogiApplication(
return userManager.Users.FirstOrDefault(u => u.Id == userId)?.UserName!;
}
public async Task<IActionResult> ReadSessionSnapshots(string sessionId)
{
var session = this.ReadSession(sessionId);
if (session == null)
{
return new NotFoundResult();
}
var snapshots = await queryRepository.ReadSessionSnapshots(sessionId);
var boardStates = snapshots.Select(snap => new Contracts.Types.BoardState
{
Board = snap.Board.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value == null
? null
: new Contracts.Types.Piece
{
IsPromoted = kvp.Value.IsPromoted,
Owner = (Contracts.Types.WhichPlayer)kvp.Value.Owner,
WhichPiece = (Contracts.Types.WhichPiece)kvp.Value.WhichPiece,
}),
Player1Hand = snap.Player1Hand.Cast<Contracts.Types.WhichPiece>().ToArray(),
Player2Hand = snap.Player2Hand.Cast<Contracts.Types.WhichPiece>().ToArray(),
PlayerInCheck = snap.PlayerInCheck == null ? null : (Contracts.Types.WhichPlayer)snap.PlayerInCheck,
Victor = snap.IsGameOver
? snap.PlayerInCheck == Repositories.Dto.SessionState.WhichPlayer.Player1 ? Contracts.Types.WhichPlayer.Player2 : Contracts.Types.WhichPlayer.Player1
: null,
WhoseTurn = (Contracts.Types.WhichPlayer)snap.WhoseTurn,
});
return new OkObjectResult(boardStates.ToArray());
}
}

View File

@@ -1,21 +1,18 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Shogi.BackEnd.Identity;
using Shogi.BackEnd.Repositories;
using Shogi.Api.Identity;
using System.Security.Claims;
namespace Shogi.BackEnd.Controllers;
namespace Shogi.Api.Controllers;
[Authorize]
[Route("backend/[controller]")]
[Route("[controller]")]
[ApiController]
public class AccountController(
SignInManager<ShogiUser> signInManager,
UserManager<ShogiUser> UserManager,
IConfiguration configuration,
SessionRepository sessionRepository,
QueryRepository queryRepository) : ControllerBase
IConfiguration configuration) : ControllerBase
{
[Authorize("Admin")]
[HttpPost("TestAccount")]
@@ -39,53 +36,21 @@ public class AccountController(
return this.Created();
}
[Authorize("Admin")]
[HttpDelete("TestAccount")]
public async Task<IActionResult> DeleteTestAccounts()
[HttpPost("/logout")]
public async Task<IActionResult> Logout([FromBody] object empty)
{
var testUsers = new[] { "aat-account", "aat-account-2" };
foreach (var username in testUsers)
// https://learn.microsoft.com/aspnet/core/blazor/security/webassembly/standalone-with-identity#antiforgery-support
if (empty is not null)
{
var user = await UserManager.FindByNameAsync(username);
if (user != null)
{
var sessions = await queryRepository.ReadSessionsMetadata(user.Id);
foreach (var session in sessions)
{
await sessionRepository.DeleteSession(session.Id);
}
await signInManager.SignOutAsync();
await UserManager.DeleteAsync(user);
}
return this.Ok();
}
return this.NoContent();
return this.Unauthorized();
}
[HttpPost("Login")]
[AllowAnonymous]
public async Task<IActionResult> Login([FromForm] string email, [FromForm] string password)
{
var result = await signInManager.PasswordSignInAsync(email, password, isPersistent: true, lockoutOnFailure: false);
if (result.Succeeded)
{
return Redirect("/");
}
return Redirect("/login?error=Invalid login attempt.");
}
[HttpGet("Logout")]
[HttpPost("Logout")]
[AllowAnonymous]
public async Task<IActionResult> Logout()
{
await signInManager.SignOutAsync();
return Redirect("/");
}
[HttpGet("/backend/roles")]
[HttpGet("/roles")]
public IActionResult GetRoles()
{
if (this.User.Identity is not null && this.User.Identity.IsAuthenticated)

View File

@@ -1,6 +1,6 @@
using System.Security.Claims;
namespace Shogi.BackEnd.Controllers;
namespace Shogi.Api.Controllers;
public static class Extentions
{

View File

@@ -15,7 +15,7 @@ using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
namespace Shogi.BackEnd.Controllers;
namespace Shogi.Api.Controllers;
/// <summary>
/// Provides extension methods for <see cref="IEndpointRouteBuilder"/> to add identity endpoints.
@@ -47,7 +47,7 @@ public static class MyIdentityApiEndpointRouteBuilderExtensions
// We'll figure out a unique endpoint name based on the final route pattern during endpoint generation.
string? confirmEmailEndpointName = null;
var routeGroup = endpoints.MapGroup("backend");
var routeGroup = endpoints.MapGroup("");
// NOTE: We cannot inject UserManager<TUser> directly because the TUser generic parameter is currently unsupported by RDG.
// https://github.com/dotnet/aspnetcore/issues/47338
@@ -407,7 +407,7 @@ public static class MyIdentityApiEndpointRouteBuilderExtensions
routeValues.Add("changedEmail", email);
}
HostString? host = environment.IsDevelopment() ? null : new HostString("api.lucaserver.space/Shogi");
HostString? host = environment.IsDevelopment() ? null : new HostString("api.lucaserver.space/Shogi.Api");
var confirmEmailUrl = linkGenerator.GetUriByName(context, confirmEmailEndpointName, routeValues, host: host)
?? throw new NotSupportedException($"Could not find endpoint named '{confirmEmailEndpointName}'.");

View File

@@ -1,15 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Shogi.BackEnd.Application;
using Shogi.BackEnd.Extensions;
using Shogi.BackEnd.Repositories;
using Shogi.BackEnd.Types;
using Shogi.Api.Application;
using Shogi.Api.Extensions;
using Shogi.Api.Repositories;
using Shogi.Contracts.Api.Commands;
using Shogi.Contracts.Types;
namespace Shogi.BackEnd.Controllers;
namespace Shogi.Api.Controllers;
[Authorize]
[ApiController]
[Route("backend/[controller]")]
[Route("[controller]")]
public class SessionsController(
SessionRepository sessionRepository,
ShogiApplication application) : ControllerBase
@@ -56,25 +57,25 @@ public class SessionsController(
[AllowAnonymous]
public async Task<ActionResult<Session>> GetSession(Guid sessionId)
{
var domainSession = await application.ReadSession(sessionId.ToString());
if (domainSession is null) return this.NotFound();
var session = await application.ReadSession(sessionId.ToString());
if (session == null) return this.NotFound();
return new Session
{
BoardState = new BoardState
{
Board = domainSession.Board.BoardState.State.ToContract(),
Player1Hand = domainSession.Board.BoardState.Player1Hand.ToContract(),
Player2Hand = domainSession.Board.BoardState.Player2Hand.ToContract(),
PlayerInCheck = domainSession.Board.BoardState.InCheck?.ToContract(),
WhoseTurn = domainSession.Board.BoardState.WhoseTurn.ToContract(),
Victor = domainSession.Board.BoardState.IsCheckmate
? domainSession.Board.BoardState.InCheck == Domains.ValueObjects.WhichPlayer.Player1 ? WhichPlayer.Player2 : WhichPlayer.Player1
Board = session.Board.BoardState.State.ToContract(),
Player1Hand = session.Board.BoardState.Player1Hand.Select(p => p.WhichPiece.ToContract()).ToArray(),
Player2Hand = session.Board.BoardState.Player2Hand.Select(p => p.WhichPiece.ToContract()).ToArray(),
PlayerInCheck = session.Board.BoardState.InCheck?.ToContract(),
WhoseTurn = session.Board.BoardState.WhoseTurn.ToContract(),
Victor = session.Board.BoardState.IsCheckmate
? session.Board.BoardState.InCheck == Domain.ValueObjects.WhichPlayer.Player1 ? WhichPlayer.Player2 : WhichPlayer.Player1
: null
},
Player1 = application.GetUsername(domainSession.Player1),
Player2 = application.GetUsername(domainSession.Player2),
SessionId = domainSession.Id
Player1 = application.GetUsername(session.Player1),
Player2 = application.GetUsername(session.Player2),
SessionId = session.Id
};
}
@@ -117,4 +118,14 @@ public class SessionsController(
return await application.MovePiece(id, sessionId, command);
}
/// <summary>
/// Returns an array of board states, one per player move of the given session, in the same order that player moves occurred.
/// </summary>
[HttpGet("{sessionId}/History")]
[AllowAnonymous]
public async Task<IActionResult> GetHistory([FromRoute] string sessionId)
{
return await application.ReadSessionSnapshots(sessionId);
}
}

View File

@@ -0,0 +1,59 @@
using Shogi.Contracts.Types;
using System.Collections.ObjectModel;
namespace Shogi.Api.Extensions;
public static class ContractsExtensions
{
public static WhichPlayer ToContract(this Domain.ValueObjects.WhichPlayer player)
{
return player switch
{
Domain.ValueObjects.WhichPlayer.Player1 => WhichPlayer.Player1,
Domain.ValueObjects.WhichPlayer.Player2 => WhichPlayer.Player2,
_ => throw new NotImplementedException(),
};
}
public static WhichPiece ToContract(this Domain.ValueObjects.WhichPiece piece)
{
return piece switch
{
Domain.ValueObjects.WhichPiece.King => WhichPiece.King,
Domain.ValueObjects.WhichPiece.GoldGeneral => WhichPiece.GoldGeneral,
Domain.ValueObjects.WhichPiece.SilverGeneral => WhichPiece.SilverGeneral,
Domain.ValueObjects.WhichPiece.Bishop => WhichPiece.Bishop,
Domain.ValueObjects.WhichPiece.Rook => WhichPiece.Rook,
Domain.ValueObjects.WhichPiece.Knight => WhichPiece.Knight,
Domain.ValueObjects.WhichPiece.Lance => WhichPiece.Lance,
Domain.ValueObjects.WhichPiece.Pawn => WhichPiece.Pawn,
_ => throw new NotImplementedException(),
};
}
public static Piece ToContract(this Domain.ValueObjects.Piece piece) => new()
{
IsPromoted = piece.IsPromoted,
Owner = piece.Owner.ToContract(),
WhichPiece = piece.WhichPiece.ToContract()
};
public static Dictionary<string, Piece?> ToContract(this ReadOnlyDictionary<string, Domain.ValueObjects.Piece?> boardState) =>
boardState.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToContract());
public static Domain.ValueObjects.WhichPiece ToDomain(this WhichPiece piece)
{
return piece switch
{
WhichPiece.King => Domain.ValueObjects.WhichPiece.King,
WhichPiece.GoldGeneral => Domain.ValueObjects.WhichPiece.GoldGeneral,
WhichPiece.SilverGeneral => Domain.ValueObjects.WhichPiece.SilverGeneral,
WhichPiece.Bishop => Domain.ValueObjects.WhichPiece.Bishop,
WhichPiece.Rook => Domain.ValueObjects.WhichPiece.Rook,
WhichPiece.Knight => Domain.ValueObjects.WhichPiece.Knight,
WhichPiece.Lance => Domain.ValueObjects.WhichPiece.Lance,
WhichPiece.Pawn => Domain.ValueObjects.WhichPiece.Pawn,
_ => throw new NotImplementedException(),
};
}
}

View File

@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace Shogi.BackEnd.Identity;
namespace Shogi.Api.Identity;
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : IdentityDbContext<ShogiUser>(options)
{

View File

@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Identity;
namespace Shogi.BackEnd.Identity;
namespace Shogi.Api.Identity;
public class ShogiUser : IdentityUser
{

View File

@@ -5,11 +5,11 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Shogi.BackEnd.Identity;
using Shogi.Api.Identity;
#nullable disable
namespace Shogi.BackEnd.Migrations
namespace Shogi.Api.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240816002834_InitialCreate")]
@@ -158,7 +158,7 @@ namespace Shogi.BackEnd.Migrations
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Shogi.Models.User", b =>
modelBuilder.Entity("Shogi.Api.Models.User", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
@@ -234,7 +234,7 @@ namespace Shogi.BackEnd.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Shogi.Models.User", null)
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -243,7 +243,7 @@ namespace Shogi.BackEnd.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Shogi.Models.User", null)
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -258,7 +258,7 @@ namespace Shogi.BackEnd.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Shogi.Models.User", null)
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -267,7 +267,7 @@ namespace Shogi.BackEnd.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Shogi.Models.User", null)
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Shogi.BackEnd.Migrations
namespace Shogi.Api.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration

View File

@@ -4,11 +4,11 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Shogi.BackEnd.Identity;
using Shogi.Api.Identity;
#nullable disable
namespace Shogi.BackEnd.Migrations
namespace Shogi.Api.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
@@ -155,7 +155,7 @@ namespace Shogi.BackEnd.Migrations
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Shogi.Models.User", b =>
modelBuilder.Entity("Shogi.Api.Models.User", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
@@ -231,7 +231,7 @@ namespace Shogi.BackEnd.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Shogi.Models.User", null)
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -240,7 +240,7 @@ namespace Shogi.BackEnd.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Shogi.Models.User", null)
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -255,7 +255,7 @@ namespace Shogi.BackEnd.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Shogi.Models.User", null)
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -264,7 +264,7 @@ namespace Shogi.BackEnd.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Shogi.Models.User", null)
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)

100
Shogi.Api/Program.cs Normal file
View File

@@ -0,0 +1,100 @@
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore;
using Shogi.Api;
using Shogi.Api.Application;
using Shogi.Api.Controllers;
using Shogi.Api.Identity;
using Shogi.Api.Repositories;
var builder = WebApplication.CreateBuilder(args);
var allowedOrigins = builder
.Configuration
.GetSection("Cors:AllowedOrigins")
.Get<string[]>() ?? throw new InvalidOperationException("Configuration for allowed origins is missing.");
builder.Services
.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.WriteIndented = true;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddTransient<SessionRepository>();
builder.Services.AddTransient<QueryRepository>();
builder.Services.AddTransient<ShogiApplication>();
builder.Services.AddTransient<GameHubContext>();
builder.Services.AddHttpClient<IEmailSender, EmailSender>();
builder.Services.Configure<ApiKeys>(builder.Configuration.GetSection("ApiKeys"));
AddIdentity(builder, builder.Configuration);
builder.Services.AddSignalR();
builder.Services.AddResponseCompression(opts =>
{
opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/octet-stream"]);
});
var app = builder.Build();
app.MyMapIdentityApi<ShogiUser>(builder.Environment);
if (app.Environment.IsDevelopment())
{
app.UseHttpsRedirection(); // Apache handles HTTPS in production.
}
else
{
app.UseResponseCompression();
}
app.UseSwagger();
app.UseSwaggerUI(options => options.DocumentTitle = "Shogi.Api");
app.UseAuthorization();
app.Map("/", () => "OK");
app.MapControllers();
app.UseCors(policy =>
{
policy.WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
});
app.MapHub<GameHub>("/gamehub");
app.Run();
static void AddIdentity(WebApplicationBuilder builder, ConfigurationManager configuration)
{
builder.Services
.AddAuthorizationBuilder()
.AddPolicy("Admin", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context => context.User?.Identity?.Name switch
{
"Hauth@live.com" => true,
"aat-account" => true,
_ => false
});
});
builder.Services
.AddDbContext<ApplicationDbContext>(options =>
{
var cs = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("Database not configured.");
options.UseSqlServer(cs);
// This is helpful to debug account issues without affecting the database.
//options.UseInMemoryDatabase("AppDb");
})
.AddIdentityApiEndpoints<ShogiUser>(options =>
{
options.SignIn.RequireConfirmedEmail = true;
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.ConfigureApplicationCookie(options =>
{
options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromDays(3);
});
}

View File

@@ -0,0 +1,24 @@
{
"profiles": {
"Kestrel": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"VaultUri": "https://gameboardshogiuisocketsv.vault.azure.net/",
"AZURE_USERNAME": "Hauth@live.com"
},
"applicationUrl": "https://localhost:5001;http://localhost:5000"
}
},
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:50728/",
"sslPort": 44315
}
}
}

View File

@@ -1,6 +1,6 @@
using Shogi.BackEnd.Domains.ValueObjects;
using Shogi.Domain.ValueObjects;
namespace Shogi.BackEnd.Repositories.Dto;
namespace Shogi.Api.Repositories.Dto;
/// <summary>
/// Useful with Dapper to read from database.

View File

@@ -1,4 +1,4 @@
namespace Shogi.BackEnd.Repositories.Dto;
namespace Shogi.Api.Repositories.Dto;
public readonly record struct SessionDto(string Id, string Player1Id, string Player2Id)
{

View File

@@ -0,0 +1,34 @@
namespace Shogi.Api.Repositories.Dto.SessionState;
public class Piece
{
public bool IsPromoted { get; set; }
public WhichPiece WhichPiece { get; set; }
public WhichPlayer Owner { get; set; }
public Piece() { }
public Piece(Domain.ValueObjects.Piece piece)
{
IsPromoted = piece.IsPromoted;
WhichPiece = piece.WhichPiece switch
{
Domain.ValueObjects.WhichPiece.Bishop => WhichPiece.Bishop,
Domain.ValueObjects.WhichPiece.GoldGeneral => WhichPiece.GoldGeneral,
Domain.ValueObjects.WhichPiece.King => WhichPiece.King,
Domain.ValueObjects.WhichPiece.SilverGeneral => WhichPiece.SilverGeneral,
Domain.ValueObjects.WhichPiece.Rook => WhichPiece.Rook,
Domain.ValueObjects.WhichPiece.Knight => WhichPiece.Knight,
Domain.ValueObjects.WhichPiece.Lance => WhichPiece.Lance,
Domain.ValueObjects.WhichPiece.Pawn => WhichPiece.Pawn,
_ => throw new NotImplementedException()
};
Owner = piece.Owner switch
{
Domain.ValueObjects.WhichPlayer.Player1 => WhichPlayer.Player1,
Domain.ValueObjects.WhichPlayer.Player2 => WhichPlayer.Player2,
_ => throw new NotImplementedException()
};
}
}

View File

@@ -0,0 +1,69 @@
namespace Shogi.Api.Repositories.Dto.SessionState;
public class SessionStateDocument
{
public long Id { get; set; }
public Dictionary<string, Piece?> Board { get; set; }
public WhichPiece[] Player1Hand { get; set; }
public WhichPiece[] Player2Hand { get; set; }
public WhichPlayer? PlayerInCheck { get; set; }
public WhichPlayer WhoseTurn { get; set; }
public bool IsGameOver { get; set; }
public string DocumentVersion { get; set; } = "1";
public SessionStateDocument() { }
public SessionStateDocument(Domain.ValueObjects.BoardState boardState)
{
this.Board = boardState.State.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value == null ? null : new Piece(kvp.Value));
this.Player1Hand = boardState.Player1Hand
.Select(piece => Map(piece.WhichPiece))
.ToArray();
this.Player2Hand = boardState.Player2Hand
.Select(piece => Map(piece.WhichPiece))
.ToArray();
this.PlayerInCheck = boardState.InCheck.HasValue
? Map(boardState.InCheck.Value)
: null;
this.IsGameOver = boardState.IsCheckmate;
}
static WhichPiece Map(Domain.ValueObjects.WhichPiece whichPiece)
{
return whichPiece switch
{
Domain.ValueObjects.WhichPiece.Bishop => WhichPiece.Bishop,
Domain.ValueObjects.WhichPiece.GoldGeneral => WhichPiece.GoldGeneral,
Domain.ValueObjects.WhichPiece.King => WhichPiece.King,
Domain.ValueObjects.WhichPiece.SilverGeneral => WhichPiece.SilverGeneral,
Domain.ValueObjects.WhichPiece.Rook => WhichPiece.Rook,
Domain.ValueObjects.WhichPiece.Knight => WhichPiece.Knight,
Domain.ValueObjects.WhichPiece.Lance => WhichPiece.Lance,
Domain.ValueObjects.WhichPiece.Pawn => WhichPiece.Pawn,
_ => throw new NotImplementedException()
};
}
static WhichPlayer Map(Domain.ValueObjects.WhichPlayer whichPlayer)
{
return whichPlayer switch
{
Domain.ValueObjects.WhichPlayer.Player1 => WhichPlayer.Player1,
Domain.ValueObjects.WhichPlayer.Player2 => WhichPlayer.Player2,
_ => throw new NotImplementedException()
};
}
}

View File

@@ -0,0 +1,13 @@
namespace Shogi.Api.Repositories.Dto.SessionState;
public enum WhichPiece
{
King,
GoldGeneral,
SilverGeneral,
Bishop,
Rook,
Knight,
Lance,
Pawn
}

View File

@@ -0,0 +1,7 @@
namespace Shogi.Api.Repositories.Dto.SessionState;
public enum WhichPlayer
{
Player1,
Player2
}

View File

@@ -3,7 +3,7 @@ using Microsoft.Extensions.Options;
using System.Net.Http.Headers;
using System.Text.Json;
namespace Shogi.BackEnd.Repositories;
namespace Shogi.Api.Repositories;
// https://app-smtp.brevo.com/real-time

View File

@@ -0,0 +1,49 @@
using Dapper;
using Shogi.Api.Repositories.Dto;
using Shogi.Api.Repositories.Dto.SessionState;
using System.Data;
using System.Data.SqlClient;
using System.Text.Json;
namespace Shogi.Api.Repositories;
public class QueryRepository(IConfiguration configuration)
{
private readonly string connectionString = configuration.GetConnectionString("ShogiDatabase")
?? throw new InvalidOperationException("No database configured for QueryRepository.");
public async Task<IEnumerable<SessionDto>> ReadSessionsMetadata(string playerId)
{
using var connection = new SqlConnection(this.connectionString);
var results = await connection.QueryMultipleAsync(
"session.ReadSessionsMetadata",
new { PlayerId = playerId },
commandType: CommandType.StoredProcedure);
return await results.ReadAsync<SessionDto>();
}
public async Task<List<SessionStateDocument>> ReadSessionSnapshots(string sessionId)
{
using var connection = new SqlConnection(this.connectionString);
var command = connection.CreateCommand();
command.CommandText = "session.ReadStatesBySession";
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("SessionId", sessionId);
await using var reader = await command.ExecuteReaderAsync();
var documents = new List<SessionStateDocument>(20);
while (await reader.ReadAsync())
{
var json = reader.GetString("Document");
var document = JsonSerializer.Deserialize<SessionStateDocument>(json);
if (document != null)
{
documents.Add(document);
}
}
return documents;
}
}

View File

@@ -1,10 +1,13 @@
using Dapper;
using Shogi.BackEnd.Repositories.Dto;
using Shogi.BackEnd.Domains.Aggregates;
using Shogi.Api.Repositories.Dto;
using Shogi.Api.Repositories.Dto.SessionState;
using Shogi.Contracts.Api.Commands;
using Shogi.Domain.Aggregates;
using System.Data;
using System.Data.SqlClient;
using System.Text.Json;
namespace Shogi.BackEnd.Repositories;
namespace Shogi.Api.Repositories;
public class SessionRepository(IConfiguration configuration)
{
@@ -52,7 +55,7 @@ public class SessionRepository(IConfiguration configuration)
return new(sessionDtos.First(), moveDtos);
}
public async Task CreateMove(string sessionId, Types.MovePieceCommand command)
public async Task CreateMove(string sessionId, MovePieceCommand command)
{
using var connection = new SqlConnection(this.connectionString);
await connection.ExecuteAsync(
@@ -80,4 +83,19 @@ public class SessionRepository(IConfiguration configuration)
},
commandType: CommandType.StoredProcedure);
}
public async Task CreateState(Session session)
{
var document = new SessionStateDocument(session.Board.BoardState);
using var connection = new SqlConnection(this.connectionString);
await connection.ExecuteAsync(
"session.CreateState",
new
{
SessionId = session.Id.ToString(),
Document = JsonSerializer.Serialize(document)
},
commandType: CommandType.StoredProcedure);
}
}

View File

@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>5</AnalysisLevel>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>False</GenerateDocumentationFile>
<SignAssembly>False</SignAssembly>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>973a1f5f-ef25-4f1c-a24d-b0fc7d016ab8</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Repositories\CouchModels\**" />
<Content Remove="Repositories\CouchModels\**" />
<EmbeddedResource Remove="Repositories\CouchModels\**" />
<None Remove="Repositories\CouchModels\**" />
</ItemGroup>
<ItemGroup>
<Compile Remove="Repositories\GameboardRepository.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="FluentValidation" Version="11.10.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.10" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<PackageReference Include="System.Data.SqlClient" Version="4.8.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Shogi.Contracts\Shogi.Contracts.csproj" />
<ProjectReference Include="..\Shogi.Domain\Shogi.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"ApiKeys": {
"BrevoEmailService": "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"
},
"TestUserPassword": "I'mAToysRUsK1d"
}

View File

@@ -1,6 +1,6 @@
{
"ConnectionStrings": {
"ShogiDatabase": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=Shogi;Integrated Security=True;Application Name=Shogi"
"ShogiDatabase": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=Shogi;Integrated Security=True;Application Name=Shogi.Api"
},
"Logging": {
"LogLevel": {

View File

@@ -1,7 +1,9 @@
using Shogi.Contracts.Types;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
namespace Shogi.BackEnd.Types;
namespace Shogi.Contracts.Api.Commands;
public partial class MovePieceCommand : IValidatableObject
{

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>5</AnalysisLevel>
<Nullable>enable</Nullable>
<GeneratePackageOnBuild>False</GeneratePackageOnBuild>
<Title>Shogi Service Models</Title>
<Description>Contains DTOs use for http requests to Shogi backend services.</Description>
</PropertyGroup>
<ItemGroup>
<Folder Include="Api\Queries\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,11 @@
using System.Text.Json;
namespace Shogi.Contracts;
public class ShogiApiJsonSerializerSettings
{
public readonly static JsonSerializerOptions SystemTextJsonSerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
};
}

View File

@@ -0,0 +1,13 @@
using System.Collections.Generic;
namespace Shogi.Contracts.Types;
public class BoardState
{
public Dictionary<string, Piece?> Board { get; set; } = [];
public IReadOnlyCollection<WhichPiece> Player1Hand { get; set; } = [];
public IReadOnlyCollection<WhichPiece> Player2Hand { get; set; } = [];
public WhichPlayer? PlayerInCheck { get; set; }
public WhichPlayer WhoseTurn { get; set; }
public WhichPlayer? Victor { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace Shogi.Contracts.Types;
public class Piece
{
public bool IsPromoted { get; set; }
public WhichPiece WhichPiece { get; set; }
public WhichPlayer Owner { get; set; }
}

View File

@@ -1,4 +1,6 @@
namespace Shogi.BackEnd.Types;
using System;
namespace Shogi.Contracts.Types;
public class Session
{
@@ -14,5 +16,5 @@ public class Session
public Guid SessionId { get; set; }
public BoardState BoardState { get; set; } = new();
public BoardState BoardState { get; set; }
}

View File

@@ -1,4 +1,6 @@
namespace Shogi.BackEnd.Types;
using System;
namespace Shogi.Contracts.Types;
public class SessionMetadata
{

View File

@@ -1,4 +1,4 @@
namespace Shogi.BackEnd.Types;
namespace Shogi.Contracts.Types;
public enum WhichPlayer
{

View File

@@ -1,4 +1,4 @@
namespace Shogi.BackEnd.Types;
namespace Shogi.Contracts.Types;
public enum WhichPiece
{

View File

@@ -1,10 +1,10 @@
-- Create a user named Shogi
-- Create a user named Shogi.Api
-- Create a role and grant execute permission to that role
--CREATE ROLE db_executor
--GRANT EXECUTE To db_executor
-- Give Shogi user permission to db_executor, db_datareader, db_datawriter
-- Give Shogi.Api user permission to db_executor, db_datareader, db_datawriter
/**
@@ -13,5 +13,5 @@
*
* 2. Setup Entity Framework because that's what the login system uses.
* 2.a. Install the Entity Framework dotnet tools, via power shell run this command: dotnet tool install --global dotnet-ef
* 2.b. To setup the Entity Framework users database, run this powershell command using Shogi as the target project: dotnet ef database update
* 2.b. To setup the Entity Framework users database, run this powershell command using Shogi.Api as the target project: dotnet ef database update
*/

View File

@@ -0,0 +1,6 @@
CREATE PROCEDURE [session].[CreateState]
@SessionId [session].[SessionSurrogateKey],
@Document NVARCHAR(MAX)
AS
INSERT INTO [session].[State] (SessionId, Document) VALUES (@SessionId, @Document);

View File

@@ -0,0 +1,8 @@
CREATE PROCEDURE [session].[ReadStatesBySession]
@SessionId [session].[SessionSurrogateKey]
AS
SELECT Id, SessionId, Document
FROM [session].[State]
WHERE Id = @SessionId
ORDER BY Id ASC;

View File

@@ -0,0 +1,9 @@
CREATE TABLE [session].[State]
(
[Id] BIGINT NOT NULL PRIMARY KEY IDENTITY,
[SessionId] [session].[SessionSurrogateKey] NOT NULL,
[Document] NVARCHAR(MAX) NOT NULL,
CONSTRAINT [FK_State_ToSession] FOREIGN KEY (SessionId) REFERENCES [session].[Session](Id),
CONSTRAINT [StateDocument must be JSON] CHECK(ISJSON(Document)=1)
)

View File

@@ -1,2 +1,3 @@
CREATE TYPE [session].[SessionSurrogateKey]
-- C# Guid
CREATE TYPE [session].[SessionSurrogateKey]
FROM CHAR(36) NOT NULL

View File

@@ -79,6 +79,9 @@
<Build Include="Session\Stored Procedures\ReadSessionsMetadata.sql" />
<Build Include="AspNetUsersId.sql" />
<Build Include="Session\Functions\MaxNewSessionsPerUser.sql" />
<Build Include="Session\Tables\State.sql" />
<Build Include="Session\Stored Procedures\CreateState.sql" />
<Build Include="Session\Stored Procedures\ReadStatesBySession.sql" />
</ItemGroup>
<ItemGroup>
<PostDeploy Include="Post Deployment\Script.PostDeployment.sql" />

View File

@@ -1,7 +1,6 @@
using Shogi.BackEnd.Domains.ValueObjects;
using Shogi.BackEnd.Domains.ValueObjects.Rules;
using Shogi.Domain.ValueObjects;
namespace Shogi.BackEnd.Domains.Aggregates;
namespace Shogi.Domain.Aggregates;
public class Session(Guid id, string player1Name)
{

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Using Include="System" />
<Using Include="System.Collections.Generic" />
<Using Include="System.Linq" />
<Using Include="System.Numerics" />
</ItemGroup>
<ItemGroup>
<Folder Include="Entities\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,47 @@
using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.ValueObjects
{
internal record class Bishop : Piece
{
private static readonly ReadOnlyCollection<Path> BishopPaths = new(new List<Path>(4)
{
new Path(Direction.ForwardLeft, Distance.MultiStep),
new Path(Direction.ForwardRight, Distance.MultiStep),
new Path(Direction.BackwardLeft, Distance.MultiStep),
new Path(Direction.BackwardRight, Distance.MultiStep)
});
public static readonly ReadOnlyCollection<Path> PromotedBishopPaths = new(new List<Path>(8)
{
new Path(Direction.Forward),
new Path(Direction.Left),
new Path(Direction.Right),
new Path(Direction.Backward),
new Path(Direction.ForwardLeft, Distance.MultiStep),
new Path(Direction.ForwardRight, Distance.MultiStep),
new Path(Direction.BackwardLeft, Distance.MultiStep),
new Path(Direction.BackwardRight, Distance.MultiStep)
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
BishopPaths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public static readonly ReadOnlyCollection<Path> Player2PromotedPaths =
PromotedBishopPaths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public Bishop(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.Bishop, owner, isPromoted)
{
}
public override IEnumerable<Path> MoveSet => IsPromoted ? PromotedBishopPaths : BishopPaths;
}
}

View File

@@ -1,11 +1,8 @@
using System.Collections.ObjectModel;
using System.Numerics;
using Shogi.BackEnd.Domains.ValueObjects.Movement;
using Shogi.BackEnd.Domains.ValueObjects.Pieces;
using Shogi.BackEnd.Domains.YetToBeAssimilatedIntoDDD;
using BoardTile = System.Collections.Generic.KeyValuePair<System.Numerics.Vector2, Shogi.BackEnd.Domains.ValueObjects.Pieces.Piece>;
using System.Collections.ObjectModel;
using Shogi.Domain.YetToBeAssimilatedIntoDDD;
using BoardTile = System.Collections.Generic.KeyValuePair<System.Numerics.Vector2, Shogi.Domain.ValueObjects.Piece>;
namespace Shogi.BackEnd.Domains.ValueObjects;
namespace Shogi.Domain.ValueObjects;
public class BoardState
{

View File

@@ -1,4 +1,4 @@
namespace Shogi.BackEnd.Domains.ValueObjects;
namespace Shogi.Domain.ValueObjects;
[Flags]
internal enum InCheckResult
@@ -32,9 +32,3 @@ public enum WhichPiece
Pawn,
//PromotedPawn,
}
public enum WhichPlayer
{
Player1,
Player2
}

View File

@@ -1,8 +1,7 @@
using Shogi.BackEnd.Domains.ValueObjects.Movement;
using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing;
using System.Collections.ObjectModel;
using Path = Shogi.BackEnd.Domains.ValueObjects.Movement.Path;
namespace Shogi.BackEnd.Domains.ValueObjects.Pieces;
namespace Shogi.Domain.ValueObjects;
internal record class GoldGeneral : Piece
{

View File

@@ -1,8 +1,7 @@
using Shogi.BackEnd.Domains.ValueObjects.Movement;
using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing;
using System.Collections.ObjectModel;
using Path = Shogi.BackEnd.Domains.ValueObjects.Movement.Path;
namespace Shogi.BackEnd.Domains.ValueObjects.Pieces;
namespace Shogi.Domain.ValueObjects;
internal record class King : Piece
{

View File

@@ -0,0 +1,32 @@
using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.ValueObjects
{
internal record class Knight : Piece
{
public static readonly ReadOnlyCollection<Path> Player1Paths = new(new List<Path>(2)
{
new Path(Direction.KnightLeft),
new Path(Direction.KnightRight)
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
Player1Paths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public Knight(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.Knight, owner, isPromoted)
{
}
public override ReadOnlyCollection<Path> MoveSet => Owner switch
{
WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths,
WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths,
_ => throw new NotImplementedException(),
};
}
}

View File

@@ -0,0 +1,31 @@
using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.ValueObjects
{
internal record class Lance : Piece
{
public static readonly ReadOnlyCollection<Path> Player1Paths = new(new List<Path>(1)
{
new Path(Direction.Forward, Distance.MultiStep),
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
Player1Paths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public Lance(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.Lance, owner, isPromoted)
{
}
public override ReadOnlyCollection<Path> MoveSet => Owner switch
{
WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths,
WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths,
_ => throw new NotImplementedException(),
};
}
}

View File

@@ -1,6 +1,4 @@
using System.Numerics;
namespace Shogi.BackEnd.Domains.ValueObjects.Movement;
namespace Shogi.Domain.ValueObjects;
/// <summary>
/// Represents a single piece being moved by a player from <paramref name="From"/> to <paramref name="To"/>.

View File

@@ -0,0 +1,6 @@
namespace Shogi.Domain.ValueObjects
{
public record MoveResult(bool IsSuccess, string Reason = "")
{
}
}

View File

@@ -0,0 +1,31 @@
using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.ValueObjects
{
internal record class Pawn : Piece
{
public static readonly ReadOnlyCollection<Path> Player1Paths = new(new List<Path>(1)
{
new Path(Direction.Forward)
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
Player1Paths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public Pawn(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.Pawn, owner, isPromoted)
{
}
public override ReadOnlyCollection<Path> MoveSet => Owner switch
{
WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths,
WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths,
_ => throw new NotImplementedException(),
};
}
}

View File

@@ -0,0 +1,99 @@
using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing;
using System.Diagnostics;
namespace Shogi.Domain.ValueObjects
{
[DebuggerDisplay("{WhichPiece} {Owner}")]
public abstract record class Piece
{
public static Piece Create(WhichPiece piece, WhichPlayer owner, bool isPromoted = false)
{
return piece switch
{
WhichPiece.King => new King(owner, isPromoted),
WhichPiece.GoldGeneral => new GoldGeneral(owner, isPromoted),
WhichPiece.SilverGeneral => new SilverGeneral(owner, isPromoted),
WhichPiece.Bishop => new Bishop(owner, isPromoted),
WhichPiece.Rook => new Rook(owner, isPromoted),
WhichPiece.Knight => new Knight(owner, isPromoted),
WhichPiece.Lance => new Lance(owner, isPromoted),
WhichPiece.Pawn => new Pawn(owner, isPromoted),
_ => throw new ArgumentException($"Unknown {nameof(WhichPiece)} when cloning a {nameof(Piece)}.")
};
}
public abstract IEnumerable<Path> MoveSet { get; }
public WhichPiece WhichPiece { get; }
public WhichPlayer Owner { get; private set; }
public bool IsPromoted { get; private set; }
protected Piece(WhichPiece piece, WhichPlayer owner, bool isPromoted = false)
{
WhichPiece = piece;
Owner = owner;
IsPromoted = isPromoted;
}
public bool CanPromote => !IsPromoted
&& WhichPiece != WhichPiece.King
&& WhichPiece != WhichPiece.GoldGeneral;
public void Promote() => IsPromoted = CanPromote;
/// <summary>
/// Prep the piece for capture by changing ownership and demoting.
/// </summary>
public void Capture(WhichPlayer newOwner)
{
Owner = newOwner;
IsPromoted = false;
}
/// <summary>
/// Respecting the move-set of the Piece, collect all positions along the shortest path from start to end.
/// Useful if you need to iterate a move-set.
/// </summary>
/// <param name="start"></param>
/// <param name="end"></param>
/// <returns>An empty list if the piece cannot legally traverse from start to end. Otherwise, a list of positions.</returns>
public IEnumerable<Vector2> GetPathFromStartToEnd(Vector2 start, Vector2 end)
{
var steps = new List<Vector2>(10);
var path = GetNearestPath(MoveSet, start, end);
var position = start;
while (Vector2.Distance(start, position) < Vector2.Distance(start, end))
{
position += path.Step;
steps.Add(position);
if (path.Distance == Distance.OneStep) break;
}
if (position == end)
{
return steps;
}
return [];
}
private static Path GetNearestPath(IEnumerable<Path> paths, Vector2 start, Vector2 end)
{
if (!paths.DefaultIfEmpty().Any())
{
throw new ArgumentException("No paths to get nearest path from.");
}
var shortestPath = paths.First();
foreach (var path in paths.Skip(1))
{
var distance = Vector2.Distance(start + path.Step, end);
var shortestDistance = Vector2.Distance(start + shortestPath.Step, end);
if (distance < shortestDistance)
{
shortestPath = path;
}
}
return shortestPath;
}
}
}

View File

@@ -1,8 +1,7 @@
using Shogi.BackEnd.Domains.ValueObjects.Movement;
using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing;
using System.Collections.ObjectModel;
using Path = Shogi.BackEnd.Domains.ValueObjects.Movement.Path;
namespace Shogi.BackEnd.Domains.ValueObjects.Pieces;
namespace Shogi.Domain.ValueObjects;
public record class Rook : Piece
{

View File

@@ -1,10 +1,5 @@
using System.Numerics;
using Shogi.BackEnd.Domains.ValueObjects.Movement;
using Shogi.BackEnd.Domains.ValueObjects.Pieces;
using Shogi.BackEnd.Domains.YetToBeAssimilatedIntoDDD;
using Path = Shogi.BackEnd.Domains.ValueObjects.Movement.Path;
namespace Shogi.BackEnd.Domains.ValueObjects.Rules;
using Shogi.Domain.YetToBeAssimilatedIntoDDD;
namespace Shogi.Domain.ValueObjects;
/// <summary>
/// Facilitates Shogi board state transitions, cognisant of Shogi rules.
@@ -252,7 +247,7 @@ public sealed class ShogiBoard(BoardState initialState)
{
var list = new List<Vector2>(10);
var position = path.Step + piecePosition;
if (path.Distance == Distance.MultiStep)
if (path.Distance == YetToBeAssimilatedIntoDDD.Pathing.Distance.MultiStep)
{
while (position.IsInsideBoardBoundary())
@@ -345,7 +340,7 @@ public sealed class ShogiBoard(BoardState initialState)
else
{
var multiStepPaths = matchingPaths
.Where(path => path.Distance == Distance.MultiStep)
.Where(path => path.Distance == YetToBeAssimilatedIntoDDD.Pathing.Distance.MultiStep)
.ToArray();
if (multiStepPaths.Length == 0)
{
@@ -376,7 +371,7 @@ public sealed class ShogiBoard(BoardState initialState)
return new MoveResult(true);
}
private static IEnumerable<Vector2> GetPositionsAlongPath(Vector2 from, Vector2 to, Path path)
private static IEnumerable<Vector2> GetPositionsAlongPath(Vector2 from, Vector2 to, YetToBeAssimilatedIntoDDD.Pathing.Path path)
{
var next = from;
while (next != to && next.X >= 0 && next.X < 9 && next.Y >= 0 && next.Y < 9)

View File

@@ -0,0 +1,35 @@
using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.ValueObjects
{
internal record class SilverGeneral : Piece
{
public static readonly ReadOnlyCollection<Path> Player1Paths = new(new List<Path>(4)
{
new Path(Direction.Forward),
new Path(Direction.ForwardLeft),
new Path(Direction.ForwardRight),
new Path(Direction.BackwardLeft),
new Path(Direction.BackwardRight)
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
Player1Paths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public SilverGeneral(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.SilverGeneral, owner, isPromoted)
{
}
public override ReadOnlyCollection<Path> MoveSet => Owner switch
{
WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths,
WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths,
_ => throw new NotImplementedException(),
};
}
}

View File

@@ -0,0 +1,166 @@
using Shogi.Domain.YetToBeAssimilatedIntoDDD;
using BoardTile = System.Collections.Generic.KeyValuePair<System.Numerics.Vector2, Shogi.Domain.ValueObjects.Piece>;
namespace Shogi.Domain.ValueObjects
{
internal class StandardRules
{
private readonly BoardState boardState;
internal StandardRules(BoardState board)
{
boardState = board;
}
/// <summary>
/// Determines if the last move put the player who moved in check.
/// </summary>
/// <remarks>
/// This strategy recognizes that a "discover check" could only occur from a subset of pieces: Rook, Bishop, Lance.
/// In this way, only those pieces need to be considered when evaluating if a move placed the moving player in check.
/// </remarks>
internal bool DidPlayerPutThemselfInCheck()
{
if (boardState.PreviousMove.From == null)
{
// You can't place yourself in check by placing a piece from your hand.
return false;
}
var previousMovedPiece = boardState[boardState.PreviousMove.To];
if (previousMovedPiece == null) throw new ArgumentNullException(nameof(previousMovedPiece), $"No piece exists at position {boardState.PreviousMove.To}.");
var kingPosition = previousMovedPiece.Owner == WhichPlayer.Player1 ? boardState.Player1KingPosition : boardState.Player2KingPosition;
var isDiscoverCheck = false;
// Get line equation from king through the now-unoccupied location.
var direction = Vector2.Subtract(kingPosition, boardState.PreviousMove.From.Value);
var slope = Math.Abs(direction.Y / direction.X);
var path = BoardState.GetPathAlongDirectionFromStartToEdgeOfBoard(boardState.PreviousMove.From.Value, Vector2.Normalize(direction));
var threat = boardState.QueryFirstPieceInPath(path);
if (threat == null || threat.Owner == previousMovedPiece.Owner) return false;
// If absolute slope is 45°, look for a bishop along the line.
// If absolute slope is 0° or 90°, look for a rook along the line.
// if absolute slope is 0°, look for lance along the line.
if (float.IsInfinity(slope))
{
isDiscoverCheck = threat.WhichPiece switch
{
WhichPiece.Lance => !threat.IsPromoted,
WhichPiece.Rook => true,
_ => false
};
}
else if (slope == 1)
{
isDiscoverCheck = threat.WhichPiece switch
{
WhichPiece.Bishop => true,
_ => false
};
}
else if (slope == 0)
{
isDiscoverCheck = threat.WhichPiece switch
{
WhichPiece.Rook => true,
_ => false
};
}
return isDiscoverCheck;
}
internal bool IsOpponentInCheckAfterMove() => IsOpposingKingThreatenedByPosition(boardState.PreviousMove.To);
internal bool IsOpposingKingThreatenedByPosition(Vector2 position)
{
var previousMovedPiece = boardState[position];
if (previousMovedPiece == null) return false;
var kingPosition = previousMovedPiece.Owner == WhichPlayer.Player1 ? boardState.Player2KingPosition : boardState.Player1KingPosition;
var path = previousMovedPiece.GetPathFromStartToEnd(position, kingPosition);
var threatenedPiece = boardState.QueryFirstPieceInPath(path);
if (!path.Any() || threatenedPiece == null) return false;
return threatenedPiece.WhichPiece == WhichPiece.King;
}
internal bool IsOpponentInCheckMate()
{
// Assume checkmate, then try to disprove.
if (!boardState.InCheck.HasValue) return false;
// Get all pieces from opponent who threaten the king in question.
var opponent = boardState.WhoseTurn == WhichPlayer.Player1 ? WhichPlayer.Player2 : WhichPlayer.Player1;
var tilesOccupiedByOpponent = boardState.GetTilesOccupiedBy(opponent);
var kingPosition = boardState.WhoseTurn == WhichPlayer.Player1
? boardState.Player1KingPosition
: boardState.Player2KingPosition;
var threats = tilesOccupiedByOpponent.Where(tile => PieceHasLineOfSight(tile, kingPosition)).ToList();
if (threats.Count == 1)
{
/* If there is exactly one threat it is possible to block the check.
* Foreach piece owned by whichPlayer
* if piece can intercept check, return false;
*/
var threat = threats.Single();
var pathFromThreatToKing = threat.Value.GetPathFromStartToEnd(threat.Key, kingPosition);
var tilesThatCouldBlockTheThreat = boardState.GetTilesOccupiedBy(boardState.WhoseTurn);
foreach (var threatBlockingPosition in pathFromThreatToKing)
{
var tilesThatDoBlockThreat = tilesThatCouldBlockTheThreat
.Where(tile => PieceHasLineOfSight(tile, threatBlockingPosition))
.ToList();
if (tilesThatDoBlockThreat.Any())
{
return false; // Cannot be check-mate if a piece can intercept the threat.
}
}
}
else
{
/*
* If no ability to block the check, maybe the king can evade check by moving.
*/
foreach (var maybeSafePosition in GetPossiblePositionsForKing(boardState.WhoseTurn))
{
threats = tilesOccupiedByOpponent
.Where(tile => PieceHasLineOfSight(tile, maybeSafePosition))
.ToList();
if (!threats.Any())
{
return false;
}
}
}
return true;
}
private List<Vector2> GetPossiblePositionsForKing(WhichPlayer whichPlayer)
{
var kingPosition = whichPlayer == WhichPlayer.Player1
? boardState.Player1KingPosition
: boardState.Player2KingPosition;
var paths = boardState[kingPosition]!.MoveSet;
return paths
.Select(path => path.Step + kingPosition)
// Because the king could be on the edge of the board, where some of its paths do not make sense.
.Where(newPosition => newPosition.IsInsideBoardBoundary())
// Where tile at position is empty, meaning the king could move there.
.Where(newPosition => boardState[newPosition] == null)
.ToList();
}
private bool PieceHasLineOfSight(BoardTile tile, Vector2 lineOfSightTarget)
{
var path = tile.Value.GetPathFromStartToEnd(tile.Key, lineOfSightTarget);
return path
.SkipLast(1)
.All(position => boardState[Notation.ToBoardNotation(position)] == null);
}
}
}

View File

@@ -0,0 +1,7 @@
namespace Shogi.Domain.ValueObjects;
public enum WhichPlayer
{
Player1,
Player2
}

View File

@@ -0,0 +1,19 @@
using Shogi.Domain.ValueObjects;
namespace Shogi.Domain.YetToBeAssimilatedIntoDDD
{
internal static class DomainExtensions
{
public static bool IsKing(this Piece self) => self.WhichPiece == WhichPiece.King;
public static bool IsBetween(this float self, float min, float max)
{
return self >= min && self <= max;
}
public static bool IsInsideBoardBoundary(this Vector2 self)
{
return self.X.IsBetween(0, 8) && self.Y.IsBetween(0, 8);
}
}
}

View File

@@ -0,0 +1,33 @@
using System.Text.RegularExpressions;
namespace Shogi.Domain.YetToBeAssimilatedIntoDDD
{
public static class Notation
{
private static readonly string BoardNotationRegex = @"(?<file>[A-I])(?<rank>[1-9])";
private static readonly char A = 'A';
public static string ToBoardNotation(Vector2 vector)
{
return ToBoardNotation((int)vector.X, (int)vector.Y);
}
public static string ToBoardNotation(int x, int y)
{
var file = (char)(x + A);
var rank = y + 1;
return $"{file}{rank}";
}
public static Vector2 FromBoardNotation(string notation)
{
if (Regex.IsMatch(notation, BoardNotationRegex))
{
var match = Regex.Match(notation, BoardNotationRegex, RegexOptions.IgnoreCase);
char file = match.Groups["file"].Value[0];
int rank = int.Parse(match.Groups["rank"].Value);
return new Vector2(file - A, rank - 1);
}
throw new ArgumentException($"Board notation not recognized. Notation given: {notation}");
}
}
}

View File

@@ -0,0 +1,15 @@
namespace Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing;
public static class Direction
{
public static readonly Vector2 Forward = new(0, 1);
public static readonly Vector2 Backward = new(0, -1);
public static readonly Vector2 Left = new(-1, 0);
public static readonly Vector2 Right = new(1, 0);
public static readonly Vector2 ForwardLeft = new(-1, 1);
public static readonly Vector2 ForwardRight = new(1, 1);
public static readonly Vector2 BackwardLeft = new(-1, -1);
public static readonly Vector2 BackwardRight = new(1, -1);
public static readonly Vector2 KnightLeft = new(-1, 2);
public static readonly Vector2 KnightRight = new(1, 2);
}

View File

@@ -1,4 +1,4 @@
namespace Shogi.BackEnd.Domains.ValueObjects.Movement;
namespace Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing;
public enum Distance
{

View File

@@ -1,7 +1,6 @@
using System.Diagnostics;
using System.Numerics;
using System.Diagnostics;
namespace Shogi.BackEnd.Domains.ValueObjects.Movement;
namespace Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing;
[DebuggerDisplay("{Step} - {Distance}")]
public record Path
@@ -18,7 +17,7 @@ public record Path
public Path(Vector2 step, Distance distance = Distance.OneStep)
{
Step = step;
Distance = distance;
this.Distance = distance;
}
public Path Invert() => new(Vector2.Negate(Step), Distance);

View File

@@ -0,0 +1,4 @@
# Shogi.Domain
### TODO:
* There are enough non-DDD classes around navigating the standard 9x9 Shogi board that probably a new value object or entity is merited. See classes within Pathing folder as well as Notation.cs.

View File

@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"microsoft.dotnet-msidentity": {
"version": "1.0.5",
"commands": [
"dotnet-msidentity"
]
}
}
}

View File

@@ -1,8 +1,8 @@
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Routes).Assembly">
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
@* <FocusOnNavigate RouteData="@routeData" Selector="h1" /> *@
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
@@ -11,4 +11,5 @@
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>

View File

@@ -0,0 +1,246 @@
namespace Shogi.UI.Identity;
using Microsoft.AspNetCore.Components.Authorization;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
/// <summary>
/// Handles state for cookie-based auth.
/// </summary>
public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IAccountManagement
{
/// <summary>
/// Map the JavaScript-formatted properties to C#-formatted classes.
/// </summary>
private readonly JsonSerializerOptions jsonSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>
/// Special auth client.
/// </summary>
private readonly HttpClient _httpClient;
/// <summary>
/// Authentication state.
/// </summary>
private bool _authenticated = false;
/// <summary>
/// Default principal for anonymous (not authenticated) users.
/// </summary>
private readonly ClaimsPrincipal Unauthenticated =
new(new ClaimsIdentity());
/// <summary>
/// Create a new instance of the auth provider.
/// </summary>
/// <param name="httpClientFactory">Factory to retrieve auth client.</param>
public CookieAuthenticationStateProvider(IHttpClientFactory httpClientFactory)
=> _httpClient = httpClientFactory.CreateClient("Auth");
/// <summary>
/// Register a new user.
/// </summary>
/// <param name="email">The user's email address.</param>
/// <param name="password">The user's password.</param>
/// <returns>The result serialized to a <see cref="FormResult"/>.
/// </returns>
public async Task<FormResult> RegisterAsync(string email, string password)
{
string[] defaultDetail = ["An unknown error prevented registration from succeeding."];
try
{
// make the request
var result = await _httpClient.PostAsJsonAsync("register", new
{
email,
password
});
// successful?
if (result.IsSuccessStatusCode)
{
return new FormResult { Succeeded = true };
}
// body should contain details about why it failed
var details = await result.Content.ReadAsStringAsync();
var problemDetails = JsonDocument.Parse(details);
var errors = new List<string>();
var errorList = problemDetails.RootElement.GetProperty("errors");
foreach (var errorEntry in errorList.EnumerateObject())
{
if (errorEntry.Value.ValueKind == JsonValueKind.String)
{
errors.Add(errorEntry.Value.GetString()!);
}
else if (errorEntry.Value.ValueKind == JsonValueKind.Array)
{
errors.AddRange(
errorEntry.Value.EnumerateArray().Select(
e => e.GetString() ?? string.Empty)
.Where(e => !string.IsNullOrEmpty(e)));
}
}
// return the error list
return new FormResult
{
Succeeded = false,
ErrorList = problemDetails == null ? defaultDetail : [.. errors]
};
}
catch { }
// unknown error
return new FormResult
{
Succeeded = false,
ErrorList = defaultDetail
};
}
/// <summary>
/// User login.
/// </summary>
/// <param name="email">The user's email address.</param>
/// <param name="password">The user's password.</param>
/// <returns>The result of the login request serialized to a <see cref="FormResult"/>.</returns>
public async Task<FormResult> LoginAsync(string email, string password)
{
try
{
// login with cookies
var result = await _httpClient.PostAsJsonAsync("login?useCookies=true", new
{
email,
password
});
// success?
if (result.IsSuccessStatusCode)
{
// need to refresh auth state
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
// success!
return new FormResult { Succeeded = true };
}
}
catch { }
// unknown error
return new FormResult
{
Succeeded = false,
ErrorList = ["Invalid email and/or password."]
};
}
/// <summary>
/// Get authentication state.
/// </summary>
/// <remarks>
/// Called by Blazor anytime and authentication-based decision needs to be made, then cached
/// until the changed state notification is raised.
/// </remarks>
/// <returns>The authentication state asynchronous request.</returns>
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
_authenticated = false;
// default to not authenticated
var user = Unauthenticated;
try
{
// the user info endpoint is secured, so if the user isn't logged in this will fail
var userResponse = await _httpClient.GetAsync("manage/info");
// throw if user info wasn't retrieved
userResponse.EnsureSuccessStatusCode();
// user is authenticated,so let's build their authenticated identity
var userJson = await userResponse.Content.ReadAsStringAsync();
var userInfo = JsonSerializer.Deserialize<UserInfo>(userJson, jsonSerializerOptions);
if (userInfo != null)
{
// in our system name and email are the same
var claims = new List<Claim>
{
new(ClaimTypes.Name, userInfo.Email),
new(ClaimTypes.Email, userInfo.Email)
};
// add any additional claims
claims.AddRange(
userInfo.Claims
.Where(c => c.Key != ClaimTypes.Name && c.Key != ClaimTypes.Email)
.Select(c => new Claim(c.Key, c.Value)));
// tap the roles endpoint for the user's roles
var rolesResponse = await _httpClient.GetAsync("roles");
// throw if request fails
rolesResponse.EnsureSuccessStatusCode();
// read the response into a string
var rolesJson = await rolesResponse.Content.ReadAsStringAsync();
// deserialize the roles string into an array
var roles = JsonSerializer.Deserialize<RoleClaim[]>(rolesJson, jsonSerializerOptions);
// if there are roles, add them to the claims collection
if (roles?.Length > 0)
{
foreach (var role in roles)
{
if (!string.IsNullOrEmpty(role.Type) && !string.IsNullOrEmpty(role.Value))
{
claims.Add(new Claim(role.Type, role.Value, role.ValueType, role.Issuer, role.OriginalIssuer));
}
}
}
// set the principal
var id = new ClaimsIdentity(claims, nameof(CookieAuthenticationStateProvider));
user = new ClaimsPrincipal(id);
_authenticated = true;
}
}
catch { }
// return the state
return new AuthenticationState(user);
}
public async Task LogoutAsync()
{
const string Empty = "{}";
var emptyContent = new StringContent(Empty, Encoding.UTF8, "application/json");
await _httpClient.PostAsync("logout", emptyContent);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
public async Task<bool> CheckAuthenticatedAsync()
{
await GetAuthenticationStateAsync();
return _authenticated;
}
public class RoleClaim
{
public string? Issuer { get; set; }
public string? OriginalIssuer { get; set; }
public string? Type { get; set; }
public string? Value { get; set; }
public string? ValueType { get; set; }
}
}

View File

@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Components.WebAssembly.Http;
namespace Shogi.UI.Identity;
public class CookieCredentialsMessageHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
request.Headers.Add("X-Requested-With", ["XMLHttpRequest"]);
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -1,4 +1,4 @@
namespace Shogi.FrontEnd.Client;
namespace Shogi.UI.Identity;
public class FormResult
{

View File

@@ -1,4 +1,4 @@
namespace Shogi.FrontEnd.Client;
namespace Shogi.UI.Identity;
/// <summary>
/// Account management services.
@@ -28,6 +28,4 @@ public interface IAccountManagement
public Task<FormResult> RegisterAsync(string email, string password);
public Task<bool> CheckAuthenticatedAsync();
Task<HttpResponseMessage> RequestPasswordReset(string email);
Task<FormResult> ChangePassword(string email, string resetCode, string newPassword);
}

View File

@@ -1,4 +1,4 @@
namespace Shogi.FrontEnd.Client;
namespace Shogi.UI.Identity;
/// <summary>
/// User info from identity endpoint to establish claims.

View File

@@ -1,6 +1,6 @@
@inherits LayoutComponentBase
<div id="app" class="MainLayout PrimaryTheme">
<div class="MainLayout PrimaryTheme">
<NavMenu />
@Body
</div>

View File

@@ -0,0 +1,12 @@
.MainLayout {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: 100vh;
place-items: stretch;
}
@media all and (max-width: 600px) {
.MainLayout {
grid-template-columns: min-content max-content;
}
}

View File

@@ -0,0 +1,52 @@
@inject NavigationManager navigator
@inject ShogiApi Api
<div class="NavMenu PrimaryTheme ThemeVariant--Contrast">
<h1>Shogi</h1>
<p>
<a href="">Home</a>
</p>
<p>
<a href="search">Search</a>
</p>
<AuthorizeView>
<p>
<button class="href" @onclick="CreateSession">Create</button>
</p>
</AuthorizeView>
<div class="spacer" />
<AuthorizeView>
<Authorized>
<p>@context.User.Identity?.Name</p>
<p>
<a href="logout">Logout</a>
</p>
</Authorized>
<NotAuthorized>
<p>
<a href="login">Login</a>
</p>
<p>
<a href="register">Register</a>
</p>
</NotAuthorized>
</AuthorizeView>
</div>
@code {
async Task CreateSession()
{
var sessionId = await Api.PostSession();
if (!string.IsNullOrEmpty(sessionId))
{
navigator.NavigateTo($"play/{sessionId}");
}
}
}

View File

@@ -0,0 +1,15 @@
.NavMenu {
display: flex;
flex-direction: column;
border-right: 2px solid #444;
}
.NavMenu > * {
padding: 0 0.5rem;
}
.NavMenu h1 {
}
.NavMenu .spacer {
flex: 1;
}

Some files were not shown because too many files have changed in this diff Show More