Merged in ssr (pull request #54)

Switch to server side rendering
This commit is contained in:
2026-01-25 19:05:11 +00:00
229 changed files with 3050 additions and 3266 deletions

1
.gitignore vendored
View File

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

120
BoardRules/BoardRules.cs Normal file
View File

@@ -0,0 +1,120 @@
//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

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

View File

@@ -3,20 +3,45 @@ 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. The application uses sockets to allow players to enjoy sessions in real time.
### Technologies used ### Technologies used
A Blazor UI backed by an Asp.net Core API service which uses Sql Server for presistent storage. A Blazor Web App which uses Sql Server for presistent storage and Identity EF Core for account management.
### 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 ### Roadmap of features remaining
The app is not yet finished, though much of the functionality exists. Here is a list of what remains. 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. * Placing pieces from the hand onto the board.
* Checkmate experience in UI * Checkmate experience
* Preventing the rarely invoked rule where check-mate cannot be gained by placing a pawn from the hand. * 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. * Retaining an archive of games played and move history of each game.
* Adaptive UI layout for varying viewport (screen) sizes. * 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

@@ -1,67 +0,0 @@
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 IReadOnlyList<Piece> ToContract(this List<Domain.ValueObjects.Piece> pieces)
{
return pieces
.Select(ToContract)
.ToList()
.AsReadOnly();
}
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,100 +0,0 @@
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

@@ -1,24 +0,0 @@
{
"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,45 +0,0 @@
<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

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

View File

@@ -1,17 +0,0 @@
<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

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

View File

@@ -1,9 +0,0 @@
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,10 +1,10 @@
-- Create a user named Shogi.Api -- Create a user named Shogi
-- Create a role and grant execute permission to that role -- Create a role and grant execute permission to that role
--CREATE ROLE db_executor --CREATE ROLE db_executor
--GRANT EXECUTE To db_executor --GRANT EXECUTE To db_executor
-- Give Shogi.Api user permission to db_executor, db_datareader, db_datawriter -- Give Shogi 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. 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.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.Api as the target project: dotnet ef database update * 2.b. To setup the Entity Framework users database, run this powershell command using Shogi as the target project: dotnet ef database update
*/ */

View File

@@ -1,20 +0,0 @@
<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

@@ -1,47 +0,0 @@
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,32 +0,0 @@
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

@@ -1,31 +0,0 @@
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 +0,0 @@
namespace Shogi.Domain.ValueObjects
{
public record MoveResult(bool IsSuccess, string Reason = "")
{
}
}

View File

@@ -1,31 +0,0 @@
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

@@ -1,99 +0,0 @@
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,35 +0,0 @@
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

@@ -1,166 +0,0 @@
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

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

View File

@@ -1,19 +0,0 @@
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

@@ -1,33 +0,0 @@
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

@@ -1,15 +0,0 @@
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 +0,0 @@
# 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

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

View File

@@ -1,279 +0,0 @@
namespace Shogi.UI.Identity;
using Microsoft.AspNetCore.Components.Authorization;
using System.Net.Http;
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;
}
/// <summary>
/// Ask for an email to be sent which contains a reset code. This reset code is used during <see cref="ChangePassword"/>
/// </summary>
/// <remarks>Do not surface errors from this to users which may tell bad actors if emails do or do not exist in the system.</remarks>
public async Task<HttpResponseMessage> RequestPasswordReset(string email)
{
return await _httpClient.PostAsJsonAsync("forgotPassword", new { email });
}
public async Task<FormResult> ChangePassword(string email, string resetCode, string newPassword)
{
var body = new
{
email,
resetCode,
newPassword
};
var response = await _httpClient.PostAsJsonAsync("resetPassword", body);
if (response.IsSuccessStatusCode)
{
return new FormResult { Succeeded = true };
}
else
{
return new FormResult
{
Succeeded = false,
ErrorList = [await response.Content.ReadAsStringAsync()]
};
}
}
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

@@ -1,14 +0,0 @@
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,48 +0,0 @@
@page "/"
@using Shogi.Contracts.Types
@using System.Net.WebSockets
@using System.Text
<main class="shogi PrimaryTheme">
<h2>What is Shogi?</h2>
<p>Shogi is a two-player strategy game where each player simultaneously protects their king while capturing their opponent's.</p>
<p>Players take turns, moving one piece each turn until check-mate is achieved.</p>
<h2>How to Play</h2>
<h3>Setup</h3>
<p>Arrange the board so it looks like this. Take note of the Rook and Bishop positions for each player.</p>
<BoardSetupVisualAid />
<!-- Margin top is because chromium browsers do not render nested grids the same as Firefox -->
<h3 style="margin-top: 2rem">Pieces and Movement</h3>
<p>Each piece has a unique set of moves. Some pieces, like the Pawn, may move only one tile per turn. Other pieces, like the Bishop, may move multiple tiles per turn.</p>
<p>A tile may only hold one piece and, except for the Knight, pieces may never move through each other.</p>
<p>Should your piece enter the tile of an opponent's piece, you must stop there and capture the opponent's piece.</p>
<PieceMovesVisualAid />
<h3>Promotion</h3>
<p>The furthest three ranks from your starting position is an area called the <b>promotion zone</b>. A piece may promote at the end of the turn when it moves in to, out of, or within the promotion zone.</p>
<p>Promoting changes the move-set available to the peice, and a piece <em>must promote</em> if it has no legal, future moves. An example of this is a Pawn moving the the furthest rank on the board such that it cannot go further. In this case, the Pawn must promote.</p>
<p>All pieces may promote <b>except for</b> the Gold General and King.</p>
<PromotedPieceVisualAid />
<h3>Capturing and the Hand</h3>
<h3>The King and "Check"</h3>
<h3>Victory</h3>
</main>
@code {
private string activeSessionName = string.Empty;
private Task OnLoginChanged()
{
StateHasChanged();
return Task.CompletedTask;
}
private void OnChangeSession(SessionMetadata s)
{
activeSessionName = s.SessionId.ToString();
StateHasChanged();
}
}

View File

@@ -1,7 +0,0 @@
.shogi {
background-color: var(--primary-color);
color: white;
padding: 1rem;
padding-top: 0;
overflow: auto;
}

View File

@@ -1 +0,0 @@
@using Shogi.UI.Pages.Home.VisualAids

View File

@@ -1,76 +0,0 @@
@page "/login"
@inject IAccountManagement Acct
@inject NavigationManager navigator
<main class="PrimaryTheme">
<h1>Login</h1>
<section class="LoginForm">
<AuthorizeView>
<Authorized>
<div>You're logged in as @context.User.Identity?.Name.</div>
</Authorized>
<NotAuthorized>
@if (errorList.Length > 0)
{
<ul class="Errors" style="grid-area: errors">
@foreach (var error in errorList)
{
<li>@error</li>
}
</ul>
}
<label for="email" style="grid-area: emailLabel">Email</label>
<input required id="email" name="emailInput" type="email" style="grid-area: emailControl" @bind-value="email" />
<label for="password" style="grid-area: passLabel">Password</label>
<input required id="password" name="passwordInput" type="password" style="grid-area: passControl" @bind-value="password" />
<a href="forgot" style="grid-area: resetLink; place-self: end;">Reset password</a>
<button style="grid-area: button" @onclick="DoLoginAsync">Login</button>
</NotAuthorized>
</AuthorizeView>
</section>
</main>
@code {
private string email = string.Empty;
private string password = string.Empty;
private string[] errorList = [];
public async Task DoLoginAsync()
{
errorList = [];
if (string.IsNullOrWhiteSpace(email))
{
errorList = ["Email is required."];
return;
}
if (string.IsNullOrWhiteSpace(password))
{
errorList = ["Password is required."];
return;
}
var result = await Acct.LoginAsync(email, password);
if (result.Succeeded)
{
email = password = string.Empty;
navigator.NavigateTo("");
}
else
{
errorList = result.ErrorList;
}
}
}

View File

@@ -1,20 +0,0 @@
main {
padding: 1rem;
}
.LoginForm {
grid-area: form;
display: inline-grid;
grid-template-areas:
"errors errors"
"emailLabel emailControl"
"passLabel passControl"
". resetLink"
"button button";
gap: 0.5rem 3rem;
}
.LoginForm .Errors {
color: darkred;
background-color: var(--foregroundColor);
}

View File

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

View File

@@ -1,75 +0,0 @@
@using Shogi.Contracts.Types
@inject ShogiApi Api
<gameBrowserEntry>
@if (showDeletePrompt)
{
<modal class="PrimaryTheme ThemeVariant--Contrast">
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
@if (showDeleteError)
{
<p style="color: darkred;">An error occurred.</p>
<div style="flex: 1;" />
<button @onclick="HideModal">Cancel</button>
}
else
{
<p>Do you wish to delete this session?</p>
<div style="flex: 1;" />
<button @onclick="HideModal">No</button>
<button @onclick="DeleteSession">Yes</button>
}
</div>
</modal>
}
<div>
<a href="play/@Session.SessionId">@Session.Player1</a>
</div>
@if (string.IsNullOrEmpty(Session.Player2))
{
<span>1 / 2</span>
}
else
{
<span>Full</span>
}
<AuthorizeView>
@if (context.User.Identity?.Name == Session.Player1)
{
<IconButton OnClick="() => showDeletePrompt = true">
<TrashCanIcon />
</IconButton>
}
</AuthorizeView>
</gameBrowserEntry>
@code {
[Parameter][EditorRequired] public SessionMetadata Session { get; set; } = default!;
[Parameter][EditorRequired] public EventCallback OnSessionDeleted { get; set; }
private bool showDeletePrompt = false;
private bool showDeleteError = false;
void HideModal()
{
showDeletePrompt = showDeleteError = false;
}
async Task DeleteSession()
{
var response = await Api.DeleteSession(Session.SessionId);
if (response.IsSuccessStatusCode)
{
showDeletePrompt = false;
showDeleteError = false;
await OnSessionDeleted.InvokeAsync();
}
else
{
showDeletePrompt = true;
showDeleteError = true;
}
}
}

View File

@@ -1,11 +0,0 @@
@page "/search"
<main class="SearchPage PrimaryTheme">
<h3>Find Sessions</h3>
<GameBrowser />
</main>
@code {
}

View File

@@ -1,3 +0,0 @@
.SearchPage {
padding: 0 0.5rem;
}

View File

@@ -1,64 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.AspNetCore.ResponseCompression;
using Shogi.UI;
using Shogi.UI.Identity;
using Shogi.UI.Shared;
using System.Text.Json;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Logging.AddConfiguration(
builder.Configuration.GetSection("Logging"));
ConfigureDependencies(builder.Services, builder.Configuration);
builder.Services.AddResponseCompression(options =>
{
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/octet-stream"]);
});
await builder.Build().RunAsync();
static void ConfigureDependencies(IServiceCollection services, IConfiguration configuration)
{
/**
* Why two HTTP clients?
* See https://docs.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/additional-scenarios?source=recommendations&view=aspnetcore-6.0#unauthenticated-or-unauthorized-web-api-requests-in-an-app-with-a-secure-default-client
*/
var baseUrl = configuration["ShogiApiUrl"];
if (string.IsNullOrWhiteSpace(baseUrl))
{
throw new InvalidOperationException("ShogiApiUrl configuration is missing.");
}
var shogiApiUrl = new Uri(baseUrl, UriKind.Absolute);
services
.AddTransient<CookieCredentialsMessageHandler>()
.AddTransient<ILocalStorage, LocalStorage>();
// Identity
services
.AddAuthorizationCore(options => options.AddPolicy("Admin", policy => policy.RequireUserName("Hauth@live.com")))
.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>()
.AddScoped(sp => (IAccountManagement)sp.GetRequiredService<AuthenticationStateProvider>())
.AddHttpClient("Auth", client => client.BaseAddress = shogiApiUrl) // "Auth" is the name expected by the auth library.
.AddHttpMessageHandler<CookieCredentialsMessageHandler>();
// Network clients
services
.AddHttpClient<ShogiApi>(client => client.BaseAddress = shogiApiUrl)
.AddHttpMessageHandler<CookieCredentialsMessageHandler>();
services
.AddSingleton<GameHubNode>();
var serializerOptions = new JsonSerializerOptions
{
WriteIndented = true
};
services.AddSingleton((sp) => serializerOptions);
}

View File

@@ -1,63 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace Shogi.UI.Properties {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Shogi.UI.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
}
}

View File

@@ -1,101 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 1.3
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">1.3</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1">this is my long string</data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
[base64 mime encoded serialized .NET Framework object]
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
[base64 mime encoded string representing a byte array form of the .NET Framework object]
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

@@ -1,15 +0,0 @@
{
"profiles": {
"Shogi.UI": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:3000",
"nativeDebugging": true
}
}
}

View File

@@ -1,8 +0,0 @@
{
"dependencies": {
"identityapp1": {
"type": "identityapp",
"dynamicId": null
}
}
}

View File

@@ -1,8 +0,0 @@
{
"dependencies": {
"identityapp1": {
"type": "identityapp.default",
"dynamicId": null
}
}
}

View File

@@ -1,79 +0,0 @@
using Shogi.Contracts.Api.Commands;
using Shogi.Contracts.Types;
using System.Net;
using System.Net.Http.Json;
using System.Reflection.Metadata.Ecma335;
using System.Text.Json;
namespace Shogi.UI.Shared;
public class ShogiApi(HttpClient httpClient)
{
private readonly JsonSerializerOptions serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
public async Task Register(string email, string password)
{
var response = await httpClient.PostAsJsonAsync(Relative("register"), new { email, password });
response.EnsureSuccessStatusCode();
}
public async Task LoginEventArgs(string email, string password)
{
var response = await httpClient.PostAsJsonAsync("login?useCookies=true", new { email, password });
response.EnsureSuccessStatusCode();
}
public async Task Logout()
{
var response = await httpClient.PutAsync(Relative("User/GuestLogout"), null);
response.EnsureSuccessStatusCode();
}
public async Task<Session?> GetSession(string name)
{
var response = await httpClient.GetAsync(Relative($"Sessions/{name}"));
if (response.IsSuccessStatusCode)
{
return (await response.Content.ReadFromJsonAsync<Session>(this.serializerOptions));
}
return null;
}
public async Task<SessionMetadata[]> GetAllSessionsMetadata()
{
var response = await httpClient.GetAsync(Relative("Sessions"));
if (response.IsSuccessStatusCode)
{
return (await response.Content.ReadFromJsonAsync<SessionMetadata[]>(this.serializerOptions))!;
}
return [];
}
/// <summary>
/// Returns false if the move was not accepted by the server.
/// </summary>
public async Task<bool> Move(Guid sessionName, MovePieceCommand command)
{
var response = await httpClient.PatchAsync(Relative($"Sessions/{sessionName}/Move"), JsonContent.Create(command));
return response.IsSuccessStatusCode;
}
public async Task<string?> PostSession()
{
var response = await httpClient.PostAsync(Relative("Sessions"), null);
var sessionId = response.IsSuccessStatusCode ? await response.Content.ReadAsStringAsync() : null;
return sessionId;
}
public Task<HttpResponseMessage> PatchJoinGame(string name)
{
return httpClient.PatchAsync(Relative($"Sessions/{name}/Join"), null);
}
public Task<HttpResponseMessage> DeleteSession(Guid sessionId)
{
return httpClient.DeleteAsync(Relative($"Sessions/{sessionId}"));
}
private static Uri Relative(string path) => new(path, UriKind.Relative);
}

View File

@@ -1,63 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<Compile Remove="zzzNotSure\**" />
<Content Remove="zzzNotSure\**" />
<EmbeddedResource Remove="zzzNotSure\**" />
<None Remove="zzzNotSure\**" />
</ItemGroup>
<ItemGroup>
<None Remove="Pages\Home\VisualAids\PromotedPieceVisualAid.razor.css" />
<None Remove="Pages\Play\GameBoard\OpponentName.razor.css" />
<None Remove="Pages\Play\GameBoard\PlayerName.razor.css" />
<None Remove="Pages\Play\GameBrowserEntry.razor.css" />
<None Remove="Pages\SearchPage.razor.css" />
</ItemGroup>
<ItemGroup>
<Content Include="Pages\Home\VisualAids\PromotedPieceVisualAid.razor.css" />
<Content Include="Pages\Play\GameBoard\OpponentName.razor.css" />
<Content Include="Pages\Play\GameBoard\PlayerName.razor.css" />
<Content Include="Pages\Play\GameBrowserEntry.razor.css" />
<Content Include="Pages\SearchPage.razor.css" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.10" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.ResponseCompression" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client.Core" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Configuration" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Shogi.Contracts\Shogi.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
</Project>

View File

@@ -1,2 +0,0 @@
{
}

View File

@@ -1,15 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Error",
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information",
"System.Net.Http.HttpClient": "Error",
"Microsoft.AspNetCore.SignalR": "Debug",
"Microsoft.AspNetCore.Http.Connections": "Debug"
}
},
"ShogiApiUrl": "https://localhost:5001",
"ShogiApiUrl2": "https://api.lucaserver.space/Shogi.Api/"
}

View File

@@ -1,33 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Shogi.UI</title>
<script type="text/javascript">
const base = document.createElement('base');
base.href = window.location.href.includes("localhost")
? "/"
: "/shogi/";
document.head.appendChild(base);
</script>
<link href="css/app.css" rel="stylesheet" />
<link href="css/themes.css" rel="stylesheet" />
<link href="Shogi.UI.styles.css" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_content/Microsoft.AspNetCore.Components.WebAssembly.Authentication/AuthenticationService.js"></script>
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>

View File

@@ -1,34 +1,27 @@
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 18
VisualStudioVersion = 17.0.31903.59 VisualStudioVersion = 18.3.11312.210
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shogi.Domain", "Shogi.Domain\Shogi.Domain.csproj", "{0211B1E4-20F0-4058-AAC4-3845D19910AF}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests", "Tests\UnitTests\UnitTests.csproj", "{4F93F735-DCCE-4A5D-ADDC-E0986DE4C48D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{A968C8E6-47B7-4F72-A27A-AC9B643FD320}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shogi.AcceptanceTests", "Tests\AcceptanceTests\Shogi.AcceptanceTests.csproj", "{30F4E3DB-027F-4885-BE06-884167C1C6CF}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shogi.Contracts", "Shogi.Contracts\Shogi.Contracts.csproj", "{1B9445A9-9C4D-46E3-8027-926C7FE0B55B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E69DE334-29A7-46AE-9647-54DC0187CD8D}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E69DE334-29A7-46AE-9647-54DC0187CD8D}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig .editorconfig = .editorconfig
.gitignore = .gitignore .gitignore = .gitignore
azure-pipelines.yml = azure-pipelines.yml azure-pipelines.yml = azure-pipelines.yml
Shogi\.config\dotnet-tools.json = Shogi\.config\dotnet-tools.json
global.json = global.json global.json = global.json
README.md = README.md README.md = README.md
EndProjectSection EndProjectSection
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shogi.UI", "Shogi.UI\Shogi.UI.csproj", "{12B90F81-AFE6-4CD5-8517-218C0D70A1B6}"
EndProject
Project("{00D1A9C2-B5F0-4AF3-8072-F6C62B433612}") = "Shogi.Database", "Shogi.Database\Shogi.Database.sqlproj", "{9B115B71-088F-41EF-858F-C7B155271A9F}" Project("{00D1A9C2-B5F0-4AF3-8072-F6C62B433612}") = "Shogi.Database", "Shogi.Database\Shogi.Database.sqlproj", "{9B115B71-088F-41EF-858F-C7B155271A9F}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shogi.Api", "Shogi.Api\Shogi.Api.csproj", "{62604006-6E18-45DA-8D5A-6ADD1C6D3CE2}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BoardRules", "BoardRules\BoardRules.csproj", "{5B2F47A0-6AD5-4DA9-9CFE-9F52F634DD5E}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "E2ETests", "Tests\E2ETests\E2ETests.csproj", "{401120C3-45D6-4A23-8D87-C2BED29F4950}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{20DA20BB-85F1-4DBE-9B22-3C4FAF89647B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "Tests\UnitTests\UnitTests.csproj", "{9D1DD2CD-7B04-4472-4377-027563F356CA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shogi", "Shogi\Shogi.csproj", "{E6BEF2A0-4372-D199-EF2D-F92A890DBC3A}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -36,45 +29,29 @@ Global
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{0211B1E4-20F0-4058-AAC4-3845D19910AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0211B1E4-20F0-4058-AAC4-3845D19910AF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0211B1E4-20F0-4058-AAC4-3845D19910AF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0211B1E4-20F0-4058-AAC4-3845D19910AF}.Release|Any CPU.Build.0 = Release|Any CPU
{4F93F735-DCCE-4A5D-ADDC-E0986DE4C48D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4F93F735-DCCE-4A5D-ADDC-E0986DE4C48D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4F93F735-DCCE-4A5D-ADDC-E0986DE4C48D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{30F4E3DB-027F-4885-BE06-884167C1C6CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{30F4E3DB-027F-4885-BE06-884167C1C6CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{30F4E3DB-027F-4885-BE06-884167C1C6CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1B9445A9-9C4D-46E3-8027-926C7FE0B55B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1B9445A9-9C4D-46E3-8027-926C7FE0B55B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1B9445A9-9C4D-46E3-8027-926C7FE0B55B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1B9445A9-9C4D-46E3-8027-926C7FE0B55B}.Release|Any CPU.Build.0 = Release|Any CPU
{12B90F81-AFE6-4CD5-8517-218C0D70A1B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{12B90F81-AFE6-4CD5-8517-218C0D70A1B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{12B90F81-AFE6-4CD5-8517-218C0D70A1B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{12B90F81-AFE6-4CD5-8517-218C0D70A1B6}.Release|Any CPU.Build.0 = Release|Any CPU
{9B115B71-088F-41EF-858F-C7B155271A9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9B115B71-088F-41EF-858F-C7B155271A9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9B115B71-088F-41EF-858F-C7B155271A9F}.Debug|Any CPU.Build.0 = Debug|Any CPU {9B115B71-088F-41EF-858F-C7B155271A9F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9B115B71-088F-41EF-858F-C7B155271A9F}.Debug|Any CPU.Deploy.0 = Debug|Any CPU {9B115B71-088F-41EF-858F-C7B155271A9F}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{9B115B71-088F-41EF-858F-C7B155271A9F}.Release|Any CPU.ActiveCfg = Release|Any CPU {9B115B71-088F-41EF-858F-C7B155271A9F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9B115B71-088F-41EF-858F-C7B155271A9F}.Release|Any CPU.Deploy.0 = Release|Any CPU {9B115B71-088F-41EF-858F-C7B155271A9F}.Release|Any CPU.Deploy.0 = Release|Any CPU
{62604006-6E18-45DA-8D5A-6ADD1C6D3CE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5B2F47A0-6AD5-4DA9-9CFE-9F52F634DD5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{62604006-6E18-45DA-8D5A-6ADD1C6D3CE2}.Debug|Any CPU.Build.0 = Debug|Any CPU {5B2F47A0-6AD5-4DA9-9CFE-9F52F634DD5E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{62604006-6E18-45DA-8D5A-6ADD1C6D3CE2}.Release|Any CPU.ActiveCfg = Release|Any CPU {5B2F47A0-6AD5-4DA9-9CFE-9F52F634DD5E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{62604006-6E18-45DA-8D5A-6ADD1C6D3CE2}.Release|Any CPU.Build.0 = Release|Any CPU {5B2F47A0-6AD5-4DA9-9CFE-9F52F634DD5E}.Release|Any CPU.Build.0 = Release|Any CPU
{401120C3-45D6-4A23-8D87-C2BED29F4950}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9D1DD2CD-7B04-4472-4377-027563F356CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{401120C3-45D6-4A23-8D87-C2BED29F4950}.Debug|Any CPU.Build.0 = Debug|Any CPU {9D1DD2CD-7B04-4472-4377-027563F356CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{401120C3-45D6-4A23-8D87-C2BED29F4950}.Release|Any CPU.ActiveCfg = Release|Any CPU {9D1DD2CD-7B04-4472-4377-027563F356CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{401120C3-45D6-4A23-8D87-C2BED29F4950}.Release|Any CPU.Build.0 = Release|Any CPU {9D1DD2CD-7B04-4472-4377-027563F356CA}.Release|Any CPU.Build.0 = Release|Any CPU
{E6BEF2A0-4372-D199-EF2D-F92A890DBC3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E6BEF2A0-4372-D199-EF2D-F92A890DBC3A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E6BEF2A0-4372-D199-EF2D-F92A890DBC3A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E6BEF2A0-4372-D199-EF2D-F92A890DBC3A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{4F93F735-DCCE-4A5D-ADDC-E0986DE4C48D} = {A968C8E6-47B7-4F72-A27A-AC9B643FD320} {9D1DD2CD-7B04-4472-4377-027563F356CA} = {20DA20BB-85F1-4DBE-9B22-3C4FAF89647B}
{30F4E3DB-027F-4885-BE06-884167C1C6CF} = {A968C8E6-47B7-4F72-A27A-AC9B643FD320}
{401120C3-45D6-4A23-8D87-C2BED29F4950} = {A968C8E6-47B7-4F72-A27A-AC9B643FD320}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {1D0B04F2-0DA1-4CB4-A82A-5A1C3B52ACEB} SolutionGuid = {1D0B04F2-0DA1-4CB4-A82A-5A1C3B52ACEB}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,14 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Shogi.Api.Controllers; using Shogi.BackEnd.Controllers;
using Shogi.Api.Extensions; using Shogi.BackEnd.Extensions;
using Shogi.Api.Identity; using Shogi.BackEnd.Identity;
using Shogi.Api.Repositories; using Shogi.BackEnd.Repositories;
using Shogi.Api.Repositories.Dto; using Shogi.BackEnd.Repositories.Dto;
using Shogi.Contracts.Api.Commands; using Shogi.BackEnd.Domains.Aggregates;
using Shogi.Domain.Aggregates;
using System.Data.SqlClient; using System.Data.SqlClient;
namespace Shogi.Api.Application; namespace Shogi.BackEnd.Application;
public class ShogiApplication( public class ShogiApplication(
QueryRepository queryRepository, QueryRepository queryRepository,
@@ -72,10 +71,10 @@ public class ShogiApplication(
return session; return session;
} }
public async Task<IActionResult> MovePiece(string playerId, string sessionId, MovePieceCommand command) public async Task<IActionResult> MovePiece(string playerId, string sessionId, Types.MovePieceCommand command)
{ {
var session = await this.ReadSession(sessionId); var session = await this.ReadSession(sessionId);
if (session == null) if (session is null)
{ {
return new NotFoundResult(); return new NotFoundResult();
} }
@@ -105,7 +104,7 @@ public class ShogiApplication(
public async Task<IActionResult> JoinSession(string sessionId, string player2Id) public async Task<IActionResult> JoinSession(string sessionId, string player2Id)
{ {
var session = await this.ReadSession(sessionId); var session = await this.ReadSession(sessionId);
if (session == null) return new NotFoundResult(); if (session is null) return new NotFoundResult();
if (string.IsNullOrEmpty(session.Player2)) if (string.IsNullOrEmpty(session.Player2))
{ {

View File

@@ -1,18 +1,21 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Shogi.Api.Identity; using Shogi.BackEnd.Identity;
using Shogi.BackEnd.Repositories;
using System.Security.Claims; using System.Security.Claims;
namespace Shogi.Api.Controllers; namespace Shogi.BackEnd.Controllers;
[Authorize] [Authorize]
[Route("[controller]")] [Route("backend/[controller]")]
[ApiController] [ApiController]
public class AccountController( public class AccountController(
SignInManager<ShogiUser> signInManager, SignInManager<ShogiUser> signInManager,
UserManager<ShogiUser> UserManager, UserManager<ShogiUser> UserManager,
IConfiguration configuration) : ControllerBase IConfiguration configuration,
SessionRepository sessionRepository,
QueryRepository queryRepository) : ControllerBase
{ {
[Authorize("Admin")] [Authorize("Admin")]
[HttpPost("TestAccount")] [HttpPost("TestAccount")]
@@ -36,21 +39,53 @@ public class AccountController(
return this.Created(); return this.Created();
} }
[HttpPost("/logout")] [Authorize("Admin")]
public async Task<IActionResult> Logout([FromBody] object empty) [HttpDelete("TestAccount")]
public async Task<IActionResult> DeleteTestAccounts()
{ {
// https://learn.microsoft.com/aspnet/core/blazor/security/webassembly/standalone-with-identity#antiforgery-support var testUsers = new[] { "aat-account", "aat-account-2" };
if (empty is not null)
foreach (var username in testUsers)
{
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 UserManager.DeleteAsync(user);
}
}
return this.NoContent();
}
[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(); await signInManager.SignOutAsync();
return Redirect("/");
return this.Ok();
} }
return this.Unauthorized(); [HttpGet("/backend/roles")]
}
[HttpGet("/roles")]
public IActionResult GetRoles() public IActionResult GetRoles()
{ {
if (this.User.Identity is not null && this.User.Identity.IsAuthenticated) if (this.User.Identity is not null && this.User.Identity.IsAuthenticated)

View File

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

View File

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

View File

@@ -1,16 +1,15 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Shogi.Api.Application; using Shogi.BackEnd.Application;
using Shogi.Api.Extensions; using Shogi.BackEnd.Extensions;
using Shogi.Api.Repositories; using Shogi.BackEnd.Repositories;
using Shogi.Contracts.Api.Commands; using Shogi.BackEnd.Types;
using Shogi.Contracts.Types;
namespace Shogi.Api.Controllers; namespace Shogi.BackEnd.Controllers;
[Authorize] [Authorize]
[ApiController] [ApiController]
[Route("[controller]")] [Route("backend/[controller]")]
public class SessionsController( public class SessionsController(
SessionRepository sessionRepository, SessionRepository sessionRepository,
ShogiApplication application) : ControllerBase ShogiApplication application) : ControllerBase
@@ -57,25 +56,25 @@ public class SessionsController(
[AllowAnonymous] [AllowAnonymous]
public async Task<ActionResult<Session>> GetSession(Guid sessionId) public async Task<ActionResult<Session>> GetSession(Guid sessionId)
{ {
var session = await application.ReadSession(sessionId.ToString()); var domainSession = await application.ReadSession(sessionId.ToString());
if (session == null) return this.NotFound(); if (domainSession is null) return this.NotFound();
return new Session return new Session
{ {
BoardState = new BoardState BoardState = new BoardState
{ {
Board = session.Board.BoardState.State.ToContract(), Board = domainSession.Board.BoardState.State.ToContract(),
Player1Hand = session.Board.BoardState.Player1Hand.ToContract(), Player1Hand = domainSession.Board.BoardState.Player1Hand.ToContract(),
Player2Hand = session.Board.BoardState.Player2Hand.ToContract(), Player2Hand = domainSession.Board.BoardState.Player2Hand.ToContract(),
PlayerInCheck = session.Board.BoardState.InCheck?.ToContract(), PlayerInCheck = domainSession.Board.BoardState.InCheck?.ToContract(),
WhoseTurn = session.Board.BoardState.WhoseTurn.ToContract(), WhoseTurn = domainSession.Board.BoardState.WhoseTurn.ToContract(),
Victor = session.Board.BoardState.IsCheckmate Victor = domainSession.Board.BoardState.IsCheckmate
? session.Board.BoardState.InCheck == Domain.ValueObjects.WhichPlayer.Player1 ? WhichPlayer.Player2 : WhichPlayer.Player1 ? domainSession.Board.BoardState.InCheck == Domains.ValueObjects.WhichPlayer.Player1 ? WhichPlayer.Player2 : WhichPlayer.Player1
: null : null
}, },
Player1 = application.GetUsername(session.Player1), Player1 = application.GetUsername(domainSession.Player1),
Player2 = application.GetUsername(session.Player2), Player2 = application.GetUsername(domainSession.Player2),
SessionId = session.Id SessionId = domainSession.Id
}; };
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
using System.Numerics;
namespace Shogi.BackEnd.Domains.ValueObjects.Movement;
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.Domain.YetToBeAssimilatedIntoDDD.Pathing; namespace Shogi.BackEnd.Domains.ValueObjects.Movement;
public enum Distance public enum Distance
{ {

View File

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

View File

@@ -0,0 +1,5 @@
namespace Shogi.BackEnd.Domains.ValueObjects.Movement;
public record MoveResult(bool IsSuccess, string Reason = "")
{
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
using Shogi.BackEnd.Domains.ValueObjects.Movement;
using System.Collections.ObjectModel;
using Path = Shogi.BackEnd.Domains.ValueObjects.Movement.Path;
namespace Shogi.BackEnd.Domains.ValueObjects.Pieces;
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.BackEnd.Domains.ValueObjects.Movement;
using System.Collections.ObjectModel;
using Path = Shogi.BackEnd.Domains.ValueObjects.Movement.Path;
namespace Shogi.BackEnd.Domains.ValueObjects.Pieces;
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

@@ -0,0 +1,31 @@
using Shogi.BackEnd.Domains.ValueObjects.Movement;
using System.Collections.ObjectModel;
using Path = Shogi.BackEnd.Domains.ValueObjects.Movement.Path;
namespace Shogi.BackEnd.Domains.ValueObjects.Pieces;
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,100 @@
using Shogi.BackEnd.Domains.ValueObjects.Movement;
using System.Diagnostics;
using System.Numerics;
using Path = Shogi.BackEnd.Domains.ValueObjects.Movement.Path;
namespace Shogi.BackEnd.Domains.ValueObjects.Pieces;
[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,7 +1,8 @@
using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing; using Shogi.BackEnd.Domains.ValueObjects.Movement;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using Path = Shogi.BackEnd.Domains.ValueObjects.Movement.Path;
namespace Shogi.Domain.ValueObjects; namespace Shogi.BackEnd.Domains.ValueObjects.Pieces;
public record class Rook : Piece public record class Rook : Piece
{ {

View File

@@ -0,0 +1,35 @@
using Shogi.BackEnd.Domains.ValueObjects.Movement;
using System.Collections.ObjectModel;
using Path = Shogi.BackEnd.Domains.ValueObjects.Movement.Path;
namespace Shogi.BackEnd.Domains.ValueObjects.Pieces;
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

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

View File

@@ -0,0 +1,166 @@
using Shogi.BackEnd.Domains.YetToBeAssimilatedIntoDDD;
using System.Numerics;
using BoardTile = System.Collections.Generic.KeyValuePair<System.Numerics.Vector2, Shogi.BackEnd.Domains.ValueObjects.Pieces.Piece>;
namespace Shogi.BackEnd.Domains.ValueObjects.Rules;
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,19 @@
using Shogi.BackEnd.Domains.ValueObjects;
using Shogi.BackEnd.Domains.ValueObjects.Pieces;
namespace Shogi.BackEnd.Domains.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 System.Numerics.Vector2 self)
{
return self.X.IsBetween(0, 8) && self.Y.IsBetween(0, 8);
}
}

View File

@@ -0,0 +1,33 @@
using System.Numerics;
using System.Text.RegularExpressions;
namespace Shogi.BackEnd.Domains.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,68 @@
using Shogi.BackEnd.Types;
using System.Collections.ObjectModel;
using DomainValueObjects = Shogi.BackEnd.Domains.ValueObjects;
namespace Shogi.BackEnd.Extensions;
public static class ContractsExtensions
{
public static WhichPlayer ToContract(this DomainValueObjects.WhichPlayer player)
{
return player switch
{
DomainValueObjects.WhichPlayer.Player1 => WhichPlayer.Player1,
DomainValueObjects.WhichPlayer.Player2 => WhichPlayer.Player2,
_ => throw new NotImplementedException(),
};
}
public static WhichPiece ToContract(this DomainValueObjects.WhichPiece piece)
{
return piece switch
{
DomainValueObjects.WhichPiece.King => WhichPiece.King,
DomainValueObjects.WhichPiece.GoldGeneral => WhichPiece.GoldGeneral,
DomainValueObjects.WhichPiece.SilverGeneral => WhichPiece.SilverGeneral,
DomainValueObjects.WhichPiece.Bishop => WhichPiece.Bishop,
DomainValueObjects.WhichPiece.Rook => WhichPiece.Rook,
DomainValueObjects.WhichPiece.Knight => WhichPiece.Knight,
DomainValueObjects.WhichPiece.Lance => WhichPiece.Lance,
DomainValueObjects.WhichPiece.Pawn => WhichPiece.Pawn,
_ => throw new NotImplementedException(),
};
}
public static Piece ToContract(this DomainValueObjects.Pieces.Piece piece) => new()
{
IsPromoted = piece.IsPromoted,
Owner = piece.Owner.ToContract(),
WhichPiece = piece.WhichPiece.ToContract()
};
public static IReadOnlyList<Piece> ToContract(this List<DomainValueObjects.Pieces.Piece> pieces)
{
return pieces
.Select(ToContract)
.ToList()
.AsReadOnly();
}
public static Dictionary<string, Piece?> ToContract(this ReadOnlyDictionary<string, DomainValueObjects.Pieces.Piece?> boardState) =>
boardState.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToContract());
public static DomainValueObjects.WhichPiece ToDomain(this WhichPiece piece)
{
return piece switch
{
WhichPiece.King => DomainValueObjects.WhichPiece.King,
WhichPiece.GoldGeneral => DomainValueObjects.WhichPiece.GoldGeneral,
WhichPiece.SilverGeneral => DomainValueObjects.WhichPiece.SilverGeneral,
WhichPiece.Bishop => DomainValueObjects.WhichPiece.Bishop,
WhichPiece.Rook => DomainValueObjects.WhichPiece.Rook,
WhichPiece.Knight => DomainValueObjects.WhichPiece.Knight,
WhichPiece.Lance => DomainValueObjects.WhichPiece.Lance,
WhichPiece.Pawn => DomainValueObjects.WhichPiece.Pawn,
_ => throw new NotImplementedException(),
};
}
}

View File

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

View File

@@ -0,0 +1,72 @@
namespace Shogi.BackEnd.Identity;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.WebUtilities;
using Shogi.FrontEnd.Client;
using System.Text;
using System.Text.Encodings.Web;
public class ServerAccountManager(
UserManager<ShogiUser> userManager,
IHttpContextAccessor httpContextAccessor,
IEmailSender emailSender) : IAccountManagement
{
public async Task<FormResult> RegisterAsync(string email, string password)
{
var user = new ShogiUser { UserName = email, Email = email };
var result = await userManager.CreateAsync(user, password);
if (result.Succeeded)
{
var userId = await userManager.GetUserIdAsync(user);
var code = await userManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var request = httpContextAccessor.HttpContext?.Request;
var host = request?.Host.Value ?? "localhost";
var scheme = request?.Scheme ?? "https";
var callbackUrl = $"{scheme}://{host}/backend/confirmEmail?userId={userId}&code={code}";
await emailSender.SendEmailAsync(email, "Confirm your email",
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
return new FormResult { Succeeded = true };
}
return new FormResult { Succeeded = false, ErrorList = result.Errors.Select(e => e.Description).ToArray() };
}
public Task<FormResult> LoginAsync(string email, string password)
{
throw new NotSupportedException("Login must be performed via Form POST to /backend/Account/Login in Server-Side Rendering.");
}
public Task LogoutAsync()
{
// Logout should be performed via Form POST or Link to /backend/Account/Logout.
return Task.CompletedTask;
}
public Task<bool> CheckAuthenticatedAsync()
{
return Task.FromResult(httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false);
}
public async Task<HttpResponseMessage> RequestPasswordReset(string email)
{
var user = await userManager.FindByEmailAsync(email);
if (user != null)
{
// Generate token and send email logic would go here.
}
return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
}
public async Task<FormResult> ChangePassword(string email, string resetCode, string newPassword)
{
var user = await userManager.FindByEmailAsync(email);
if (user == null) return new FormResult { Succeeded = false, ErrorList = ["User not found"] };
var result = await userManager.ResetPasswordAsync(user, resetCode, newPassword);
return new FormResult { Succeeded = result.Succeeded, ErrorList = result.Errors.Select(e => e.Description).ToArray() };
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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