Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 460dfd608e | |||
| 13e79eb490 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -55,4 +55,3 @@ obj
|
||||
*.user
|
||||
/Shogi.Database/Shogi.Database.dbmdl
|
||||
/Shogi.Database/Shogi.Database.jfm
|
||||
/Shogi/appsettings.Development.json
|
||||
|
||||
@@ -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())
|
||||
// }
|
||||
//}
|
||||
@@ -1,9 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
43
README.md
43
README.md
@@ -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.
|
||||
* Adaptive UI layout for varying viewport (screen) sizes.
|
||||
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.2",
|
||||
"version": "6.0.5",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Shogi;
|
||||
namespace Shogi.Api;
|
||||
|
||||
public class ApiKeys
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Shogi.BackEnd.Application;
|
||||
namespace Shogi.Api.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Used to receive signals from connected clients.
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Shogi.BackEnd.Application;
|
||||
namespace Shogi.Api.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Used to send signals to connected clients.
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Shogi.BackEnd.Controllers;
|
||||
namespace Shogi.Api.Controllers;
|
||||
|
||||
public static class Extentions
|
||||
{
|
||||
@@ -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}'.");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
59
Shogi.Api/Extensions/ContractsExtensions.cs
Normal file
59
Shogi.Api/Extensions/ContractsExtensions.cs
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Shogi.BackEnd.Identity;
|
||||
namespace Shogi.Api.Identity;
|
||||
|
||||
public class ShogiUser : IdentityUser
|
||||
{
|
||||
@@ -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)
|
||||
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Shogi.BackEnd.Migrations
|
||||
namespace Shogi.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
@@ -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
100
Shogi.Api/Program.cs
Normal 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);
|
||||
});
|
||||
|
||||
}
|
||||
24
Shogi.Api/Properties/launchSettings.json
Normal file
24
Shogi.Api/Properties/launchSettings.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
{
|
||||
34
Shogi.Api/Repositories/Dto/SessionState/Piece.cs
Normal file
34
Shogi.Api/Repositories/Dto/SessionState/Piece.cs
Normal 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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
13
Shogi.Api/Repositories/Dto/SessionState/WhichPiece.cs
Normal file
13
Shogi.Api/Repositories/Dto/SessionState/WhichPiece.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Shogi.Api.Repositories.Dto.SessionState;
|
||||
|
||||
public enum WhichPiece
|
||||
{
|
||||
King,
|
||||
GoldGeneral,
|
||||
SilverGeneral,
|
||||
Bishop,
|
||||
Rook,
|
||||
Knight,
|
||||
Lance,
|
||||
Pawn
|
||||
}
|
||||
7
Shogi.Api/Repositories/Dto/SessionState/WhichPlayer.cs
Normal file
7
Shogi.Api/Repositories/Dto/SessionState/WhichPlayer.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Shogi.Api.Repositories.Dto.SessionState;
|
||||
|
||||
public enum WhichPlayer
|
||||
{
|
||||
Player1,
|
||||
Player2
|
||||
}
|
||||
@@ -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
|
||||
|
||||
49
Shogi.Api/Repositories/QueryRepository.cs
Normal file
49
Shogi.Api/Repositories/QueryRepository.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
45
Shogi.Api/Shogi.Api.csproj
Normal file
45
Shogi.Api/Shogi.Api.csproj
Normal 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>
|
||||
13
Shogi.Api/appsettings.Development.json
Normal file
13
Shogi.Api/appsettings.Development.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"ApiKeys": {
|
||||
"BrevoEmailService": "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"
|
||||
},
|
||||
"TestUserPassword": "I'mAToysRUsK1d"
|
||||
}
|
||||
@@ -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": {
|
||||
@@ -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
|
||||
{
|
||||
17
Shogi.Contracts/Shogi.Contracts.csproj
Normal file
17
Shogi.Contracts/Shogi.Contracts.csproj
Normal 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>
|
||||
11
Shogi.Contracts/ShogiApiJsonSerializerSettings.cs
Normal file
11
Shogi.Contracts/ShogiApiJsonSerializerSettings.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
13
Shogi.Contracts/Types/BoardState.cs
Normal file
13
Shogi.Contracts/Types/BoardState.cs
Normal 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; }
|
||||
}
|
||||
8
Shogi.Contracts/Types/Piece.cs
Normal file
8
Shogi.Contracts/Types/Piece.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace Shogi.BackEnd.Types;
|
||||
using System;
|
||||
|
||||
namespace Shogi.Contracts.Types;
|
||||
|
||||
public class SessionMetadata
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Shogi.BackEnd.Types;
|
||||
namespace Shogi.Contracts.Types;
|
||||
|
||||
public enum WhichPlayer
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Shogi.BackEnd.Types;
|
||||
namespace Shogi.Contracts.Types;
|
||||
|
||||
public enum WhichPiece
|
||||
{
|
||||
@@ -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
|
||||
*/
|
||||
6
Shogi.Database/Session/Stored Procedures/CreateState.sql
Normal file
6
Shogi.Database/Session/Stored Procedures/CreateState.sql
Normal 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);
|
||||
@@ -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;
|
||||
9
Shogi.Database/Session/Tables/State.sql
Normal file
9
Shogi.Database/Session/Tables/State.sql
Normal 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)
|
||||
)
|
||||
@@ -1,2 +1,3 @@
|
||||
CREATE TYPE [session].[SessionSurrogateKey]
|
||||
-- C# Guid
|
||||
CREATE TYPE [session].[SessionSurrogateKey]
|
||||
FROM CHAR(36) NOT NULL
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
20
Shogi.Domain/Shogi.Domain.csproj
Normal file
20
Shogi.Domain/Shogi.Domain.csproj
Normal 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>
|
||||
47
Shogi.Domain/ValueObjects/Bishop.cs
Normal file
47
Shogi.Domain/ValueObjects/Bishop.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
@@ -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
|
||||
{
|
||||
32
Shogi.Domain/ValueObjects/Knight.cs
Normal file
32
Shogi.Domain/ValueObjects/Knight.cs
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
31
Shogi.Domain/ValueObjects/Lance.cs
Normal file
31
Shogi.Domain/ValueObjects/Lance.cs
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"/>.
|
||||
6
Shogi.Domain/ValueObjects/MoveResult.cs
Normal file
6
Shogi.Domain/ValueObjects/MoveResult.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Shogi.Domain.ValueObjects
|
||||
{
|
||||
public record MoveResult(bool IsSuccess, string Reason = "")
|
||||
{
|
||||
}
|
||||
}
|
||||
31
Shogi.Domain/ValueObjects/Pawn.cs
Normal file
31
Shogi.Domain/ValueObjects/Pawn.cs
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
99
Shogi.Domain/ValueObjects/Piece.cs
Normal file
99
Shogi.Domain/ValueObjects/Piece.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
@@ -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)
|
||||
35
Shogi.Domain/ValueObjects/SilverGeneral.cs
Normal file
35
Shogi.Domain/ValueObjects/SilverGeneral.cs
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
166
Shogi.Domain/ValueObjects/StandardRules.cs
Normal file
166
Shogi.Domain/ValueObjects/StandardRules.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
Shogi.Domain/ValueObjects/WhichPlayer.cs
Normal file
7
Shogi.Domain/ValueObjects/WhichPlayer.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Shogi.Domain.ValueObjects;
|
||||
|
||||
public enum WhichPlayer
|
||||
{
|
||||
Player1,
|
||||
Player2
|
||||
}
|
||||
19
Shogi.Domain/YetToBeAssimilatedIntoDDD/DomainExtensions.cs
Normal file
19
Shogi.Domain/YetToBeAssimilatedIntoDDD/DomainExtensions.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
Shogi.Domain/YetToBeAssimilatedIntoDDD/Notation.cs
Normal file
33
Shogi.Domain/YetToBeAssimilatedIntoDDD/Notation.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
15
Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Direction.cs
Normal file
15
Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Direction.cs
Normal 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);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Shogi.BackEnd.Domains.ValueObjects.Movement;
|
||||
namespace Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing;
|
||||
|
||||
public enum Distance
|
||||
{
|
||||
@@ -10,4 +10,4 @@ public enum Distance
|
||||
/// Signifies that a piece can move multiple tiles/positions in a single move.
|
||||
/// </summary>
|
||||
MultiStep
|
||||
}
|
||||
}
|
||||
@@ -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,8 +17,8 @@ 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);
|
||||
}
|
||||
}
|
||||
4
Shogi.Domain/YetToBeAssimilatedIntoDDD/ReadMe.md
Normal file
4
Shogi.Domain/YetToBeAssimilatedIntoDDD/ReadMe.md
Normal 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.
|
||||
12
Shogi.UI/.config/dotnet-tools.json
Normal file
12
Shogi.UI/.config/dotnet-tools.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"microsoft.dotnet-msidentity": {
|
||||
"version": "1.0.5",
|
||||
"commands": [
|
||||
"dotnet-msidentity"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
246
Shogi.UI/Identity/CookieAuthenticationStateProvider.cs
Normal file
246
Shogi.UI/Identity/CookieAuthenticationStateProvider.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
14
Shogi.UI/Identity/CookieMessageHandler.cs
Normal file
14
Shogi.UI/Identity/CookieMessageHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Shogi.FrontEnd.Client;
|
||||
namespace Shogi.UI.Identity;
|
||||
|
||||
public class FormResult
|
||||
{
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Shogi.FrontEnd.Client;
|
||||
namespace Shogi.UI.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// User info from identity endpoint to establish claims.
|
||||
@@ -1,6 +1,6 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div id="app" class="MainLayout PrimaryTheme">
|
||||
<div class="MainLayout PrimaryTheme">
|
||||
<NavMenu />
|
||||
@Body
|
||||
</div>
|
||||
12
Shogi.UI/Layout/MainLayout.razor.css
Normal file
12
Shogi.UI/Layout/MainLayout.razor.css
Normal 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;
|
||||
}
|
||||
}
|
||||
52
Shogi.UI/Layout/NavMenu.razor
Normal file
52
Shogi.UI/Layout/NavMenu.razor
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
15
Shogi.UI/Layout/NavMenu.razor.css
Normal file
15
Shogi.UI/Layout/NavMenu.razor.css
Normal 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
Reference in New Issue
Block a user