28 Commits

Author SHA1 Message Date
1bcd8ed2ac Add IDesignTimeDbContextFactory for pipeline EF bundle 2026-01-26 19:19:57 -06:00
7f3cbcfdef fix yml 2026-01-25 16:57:33 -06:00
6de8728ce6 Merged in ssr (pull request #54)
Switch to server side rendering
2026-01-25 19:05:11 +00:00
7e5951da2d remove aat project from solution 2026-01-25 12:46:00 -06:00
78733295e0 yep 2026-01-16 17:14:03 -06:00
2eba8cdb0e yep 2026-01-15 22:53:07 -06:00
c7cf706d6c Update pipeline for ssr 2026-01-15 20:06:16 -06:00
79dd554afa yep 2026-01-15 20:06:01 -06:00
4c52b3bde4 update dotnet tools 2026-01-15 20:05:22 -06:00
114025fcfb all the things 2026-01-14 22:04:37 -06:00
a3f23b199a ignore appsettings.dev file 2026-01-14 20:36:38 -06:00
8c65125b16 yep 2026-01-13 23:21:23 -06:00
334c2fecb5 Commit changes before fixing global.json file(s). 2026-01-13 21:47:52 -06:00
a4b08f4cf1 checkpoint 2026-01-13 20:36:03 -06:00
d9f48244aa yep 2025-12-31 09:03:05 -06:00
dcbf8a3ac3 convert to blazor server side render 2025-12-24 16:44:42 -06:00
357c3d9932 Upgrade to .net 10 2025-12-22 11:44:06 -06:00
0a415a2292 checkpoint 2025-09-05 18:13:35 -05:00
e2a8b771d9 const over var 2025-05-11 15:46:26 -05:00
0431fb2950 yep 2025-05-04 13:45:30 -05:00
83dd4cc4a3 Group pieces in the hand. 2024-11-26 15:26:47 -06:00
1ed1ad09af Forgot password 2024-11-26 17:42:46 +00:00
964f3bfb30 Fix "Login to play!" when session has 2 players. 2024-11-17 10:12:43 -06:00
fe4b013ed0 Fix navbar text alignment 2024-11-17 09:28:57 -06:00
e87bdf8b52 Style boost 2024-11-16 23:13:56 -06:00
fbdaf29f43 Style boost 2024-11-16 21:56:46 -06:00
b5c6b8244d Style boost 2024-11-16 21:39:17 -06:00
48ab8f7964 style boost 2024-11-16 20:02:56 -06:00
247 changed files with 3946 additions and 3989 deletions

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +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 ROLE 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.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,6 +0,0 @@
CREATE PROCEDURE [session].[CreateState]
@SessionId [session].[SessionSurrogateKey],
@Document NVARCHAR(MAX)
AS
INSERT INTO [session].[State] (SessionId, Document) VALUES (@SessionId, @Document);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,55 +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 bool show = true;
private string activeSessionName = string.Empty;
private Task OnLoginChanged()
{
StateHasChanged();
return Task.CompletedTask;
}
private void OnChangeSession(SessionMetadata s)
{
activeSessionName = s.SessionId.ToString();
StateHasChanged();
}
private void OnClickClose()
{
show = false;
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,74 +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" />
<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,28 +0,0 @@
main {
/*display: grid;
grid-template-areas:
"header header header"
". form ."
". . .";
grid-template-rows: auto 1fr 1fr;
place-items: center;
*/
}
.LoginForm {
grid-area: form;
display: inline-grid;
grid-template-areas:
"errors errors"
"emailLabel emailControl"
"passLabel passControl"
"button button";
gap: 0.5rem 3rem;
}
.LoginForm .Errors {
color: darkred;
}

View File

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

View File

@@ -1,208 +0,0 @@
@using Shogi.Contracts.Types;
@using System.Text.Json;
<article class="game-board">
<!-- Controls -->
<header class="controls">
<form @onsubmit:preventDefault>
<fieldset class="history" disabled=@Yep>
<legend>Replay</legend>
<button disabled=@Yep>&lt;</button>
<button>&gt;</button>
</fieldset>
</form>
</header>
<!-- Game board -->
<section class="board" data-perspective="@Perspective">
@for (var rank = 1; rank < 10; rank++)
{
foreach (var file in Files)
{
var position = $"{file}{rank}";
var piece = Session?.BoardState.Board[position];
var isSelected = piece != null && SelectedPosition == position;
<div class="tile" @onclick="OnClickTileInternal(position)"
data-position="@(position)"
data-selected="@(isSelected)"
style="grid-area: @position">
@if (piece != null)
{
<GamePiece Piece="piece.WhichPiece" RenderUpsideDown="@(piece.Owner != Perspective)" IsPromoted="piece.IsPromoted" />
}
</div>
}
}
<div class="ruler vertical" style="grid-area: rank">
<span>9</span>
<span>8</span>
<span>7</span>
<span>6</span>
<span>5</span>
<span>4</span>
<span>3</span>
<span>2</span>
<span>1</span>
</div>
<div class="ruler" style="grid-area: file">
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
<span>E</span>
<span>F</span>
<span>G</span>
<span>H</span>
<span>I</span>
</div>
</section>
<!-- Side board -->
@if (Session != null && UseSideboard == true)
{
<aside class="side-board PrimaryTheme ThemeVariant--Contrast">
<div class="player-area">
<div class="hand">
@if (opponentHand.Any())
{
@foreach (var piece in opponentHand)
{
<div class="tile">
<GamePiece Piece="piece" RenderUpsideDown="true" />
</div>
}
}
</div>
<p class="text-center">Opponent's Hand</p>
</div>
<div class="place-self-center">
<PlayerName Name="@opponentName" IsTurn="!IsMyTurn" InCheck="IsOpponentInCheck" IsVictor="IsOpponentVictor" />
<hr />
<PlayerName Name="@userName" IsTurn="IsMyTurn" InCheck="IsPlayerInCheck" IsVictor="IsPlayerVictor" />
</div>
<div class="player-area">
@if (Perspective == WhichPlayer.Player2 && string.IsNullOrEmpty(Session.Player2))
{
<AuthorizeView>
<div class="place-self-center">
<button @onclick="OnClickJoinGameInternal">Join Game</button>
</div>
</AuthorizeView>
}
else
{
<p class="text-center">Your Hand</p>
<div class="hand">
@if (userHand.Any())
{
@foreach (var whichPiece in userHand)
{
<div @onclick="OnClickHandInternal(whichPiece)"
class="tile"
data-selected="@(whichPiece == SelectedPieceFromHand)">
<GamePiece Piece="whichPiece" RenderUpsideDown="false" />
</div>
}
}
</div>
}
</div>
</aside>
}
</article>
@code {
static readonly string[] Files = new[] { "A", "B", "C", "D", "E", "F", "G", "H", "I" };
/// <summary>
/// When true, an icon is displayed indicating that the user is spectating.
/// </summary>
[Parameter] public bool IsSpectating { get; set; } = false;
[Parameter] public WhichPlayer Perspective { get; set; }
[Parameter] public Session? Session { get; set; }
[Parameter] public string? SelectedPosition { get; set; }
[Parameter] public WhichPiece? SelectedPieceFromHand { get; set; }
// TODO: Exchange these OnClick actions for events like "SelectionChangedEvent" and "MoveFromBoardEvent" and "MoveFromHandEvent".
[Parameter] public EventCallback<string> OnClickTile { get; set; }
[Parameter] public EventCallback<WhichPiece> OnClickHand { get; set; }
[Parameter] public EventCallback OnClickJoinGame { get; set; }
[Parameter] public bool UseSideboard { get; set; } = true;
[Parameter] public IList<BoardState> History { get; set; }
private bool Yep => History.Count == 0;
private IReadOnlyCollection<WhichPiece> opponentHand;
private IReadOnlyCollection<WhichPiece> userHand;
private string? userName;
private string? opponentName;
private int historyIndex;
public GameBoardPresentation()
{
opponentHand = [];
userHand = [];
userName = string.Empty;
opponentName = string.Empty;
History = [];
}
protected override void OnParametersSet()
{
base.OnParametersSet();
if (Session == null)
{
opponentHand = [];
userHand = [];
userName = string.Empty;
opponentName = string.Empty;
}
else
{
opponentHand = Perspective == WhichPlayer.Player1
? this.Session.BoardState.Player2Hand
: this.Session.BoardState.Player1Hand;
userHand = Perspective == WhichPlayer.Player1
? this.Session.BoardState.Player1Hand
: this.Session.BoardState.Player2Hand;
userName = Perspective == WhichPlayer.Player1
? this.Session.Player1
: this.Session.Player2 ?? "Empty Seat";
opponentName = Perspective == WhichPlayer.Player1
? this.Session.Player2 ?? "Empty Seat"
: this.Session.Player1;
}
Console.WriteLine("Count: {0}", History.Count);
}
private bool IsMyTurn => Session?.BoardState.WhoseTurn == Perspective;
private bool IsPlayerInCheck => Session?.BoardState.PlayerInCheck == Perspective;
private bool IsOpponentInCheck => Session?.BoardState.PlayerInCheck != null && Session.BoardState.PlayerInCheck != Perspective;
private bool IsPlayerVictor => Session?.BoardState.Victor == Perspective;
private bool IsOpponentVictor => Session?.BoardState.Victor != null && Session.BoardState.Victor != Perspective;
private Func<Task> OnClickTileInternal(string position) => () =>
{
if (IsMyTurn)
{
return OnClickTile.InvokeAsync(position);
}
return Task.CompletedTask;
};
private Func<Task> OnClickHandInternal(WhichPiece piece) => () =>
{
if (IsMyTurn)
{
return OnClickHand.InvokeAsync(piece);
}
return Task.CompletedTask;
};
private Task OnClickJoinGameInternal() => OnClickJoinGame.InvokeAsync();
}

View File

@@ -1,122 +0,0 @@
.game-board {
--ratio: 0.9;
display: grid;
grid-template-columns: min-content repeat(9, minmax(2rem, 4rem)) max-content;
grid-template-rows: auto repeat(9, 1fr) auto;
background-color: #444;
gap: 3px;
place-self: center;
}
.board {
display: grid;
grid-column: span 10;
grid-row: span 10;
grid-template-areas:
"rank A9 B9 C9 D9 E9 F9 G9 H9 I9"
"rank A8 B8 C8 D8 E8 F8 G8 H8 I8"
"rank A7 B7 C7 D7 E7 F7 G7 H7 I7"
"rank A6 B6 C6 D6 E6 F6 G6 H6 I6"
"rank A5 B5 C5 D5 E5 F5 G5 H5 I5"
"rank A4 B4 C4 D4 E4 F4 G4 H4 I4"
"rank A3 B3 C3 D3 E3 F3 G3 H3 I3"
"rank A2 B2 C2 D2 E2 F2 G2 H2 I2"
"rank A1 B1 C1 D1 E1 F1 G1 H1 I1"
". file file file file file file file file file";
grid-template-columns: subgrid;
grid-template-rows: subgrid;
background-color: #444444;
max-height: 100%;
aspect-ratio: var(--ratio);
}
.board[data-perspective="Player2"] {
grid-template-areas:
"rank I1 H1 G1 F1 E1 D1 C1 B1 A1"
"rank I2 H2 G2 F2 E2 D2 C2 B2 A2"
"rank I3 H3 G3 F3 E3 D3 C3 B3 A3"
"rank I4 H4 G4 F4 E4 D4 C4 B4 A4"
"rank I5 H5 G5 F5 E5 D5 C5 B5 A5"
"rank I6 H6 G6 F6 E6 D6 C6 B6 A6"
"rank I7 H7 G7 F7 E7 D7 C7 B7 A7"
"rank I8 H8 G8 F8 E8 D8 C8 B8 A8"
"rank I9 H9 G9 F9 E9 D9 C9 B9 A9"
". file file file file file file file file file";
}
.tile {
display: grid;
place-content: center;
aspect-ratio: var(--ratio);
background-color: beige;
transition: filter linear 0.25s;
}
.tile[data-selected] {
filter: invert(0.8);
}
.ruler {
color: beige;
display: flex;
flex-direction: row;
justify-content: space-around;
}
.ruler.vertical {
flex-direction: column;
}
.board[data-perspective="Player2"] .ruler {
flex-direction: row-reverse;
}
.board[data-perspective="Player2"] .ruler.vertical {
flex-direction: column-reverse;
}
.side-board {
grid-row: span 9;
display: flex;
flex-direction: column;
place-content: space-between;
padding: 1rem;
}
.side-board .player-area {
display: grid;
place-items: stretch;
}
.side-board .hand {
display: grid;
border: 1px solid #ccc;
grid-template-columns: repeat(auto-fill, 3rem);
grid-template-rows: 3rem;
place-items: center start;
padding: 0.5rem;
}
.controls {
grid-column: span 11;
display: flex;
}
.controls .history {
}
@media all and (max-width: 1000px) {
.game-board {
grid-template-columns: min-content repeat(9, minmax(2rem, 4rem));
grid-template-rows: repeat(9, 1fr) auto max-content;
}
.side-board {
grid-row: unset;
grid-column: span 10;
flex-direction: row;
}
}

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

View File

@@ -1,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,65 +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.Pages.Play;
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 qhttps://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,62 +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\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\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.Authentication.WebAssembly.Msal" 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">
var 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
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
# Visual Studio Version 18
VisualStudioVersion = 18.3.11312.210
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}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
.gitignore = .gitignore
azure-pipelines.yml = azure-pipelines.yml
Shogi\.config\dotnet-tools.json = Shogi\.config\dotnet-tools.json
global.json = global.json
README.md = README.md
EndProjectSection
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}"
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
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
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -36,45 +29,29 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
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.Build.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.Deploy.0 = Release|Any CPU
{62604006-6E18-45DA-8D5A-6ADD1C6D3CE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{62604006-6E18-45DA-8D5A-6ADD1C6D3CE2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{62604006-6E18-45DA-8D5A-6ADD1C6D3CE2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{62604006-6E18-45DA-8D5A-6ADD1C6D3CE2}.Release|Any CPU.Build.0 = Release|Any CPU
{401120C3-45D6-4A23-8D87-C2BED29F4950}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{401120C3-45D6-4A23-8D87-C2BED29F4950}.Debug|Any CPU.Build.0 = Debug|Any CPU
{401120C3-45D6-4A23-8D87-C2BED29F4950}.Release|Any CPU.ActiveCfg = Release|Any CPU
{401120C3-45D6-4A23-8D87-C2BED29F4950}.Release|Any CPU.Build.0 = Release|Any CPU
{5B2F47A0-6AD5-4DA9-9CFE-9F52F634DD5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5B2F47A0-6AD5-4DA9-9CFE-9F52F634DD5E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5B2F47A0-6AD5-4DA9-9CFE-9F52F634DD5E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5B2F47A0-6AD5-4DA9-9CFE-9F52F634DD5E}.Release|Any CPU.Build.0 = Release|Any CPU
{9D1DD2CD-7B04-4472-4377-027563F356CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9D1DD2CD-7B04-4472-4377-027563F356CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9D1DD2CD-7B04-4472-4377-027563F356CA}.Release|Any CPU.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{4F93F735-DCCE-4A5D-ADDC-E0986DE4C48D} = {A968C8E6-47B7-4F72-A27A-AC9B643FD320}
{30F4E3DB-027F-4885-BE06-884167C1C6CF} = {A968C8E6-47B7-4F72-A27A-AC9B643FD320}
{401120C3-45D6-4A23-8D87-C2BED29F4950} = {A968C8E6-47B7-4F72-A27A-AC9B643FD320}
{9D1DD2CD-7B04-4472-4377-027563F356CA} = {20DA20BB-85F1-4DBE-9B22-3C4FAF89647B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {1D0B04F2-0DA1-4CB4-A82A-5A1C3B52ACEB}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
{

View File

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

View File

@@ -1,4 +1,4 @@
namespace Shogi.Domain.ValueObjects;
namespace Shogi.BackEnd.Domains.ValueObjects;
[Flags]
internal enum InCheckResult
@@ -32,3 +32,9 @@ public enum WhichPiece
Pawn,
//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
{

View File

@@ -1,4 +1,6 @@
namespace Shogi.Domain.ValueObjects;
using System.Numerics;
namespace Shogi.BackEnd.Domains.ValueObjects.Movement;
/// <summary>
/// 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}")]
public record Path
@@ -17,7 +18,7 @@ public record Path
public Path(Vector2 step, Distance distance = Distance.OneStep)
{
Step = step;
this.Distance = distance;
Distance = 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 Path = Shogi.BackEnd.Domains.ValueObjects.Movement.Path;
namespace Shogi.Domain.ValueObjects;
namespace Shogi.BackEnd.Domains.ValueObjects.Pieces;
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 Path = Shogi.BackEnd.Domains.ValueObjects.Movement.Path;
namespace Shogi.Domain.ValueObjects;
namespace Shogi.BackEnd.Domains.ValueObjects.Pieces;
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 Path = Shogi.BackEnd.Domains.ValueObjects.Movement.Path;
namespace Shogi.Domain.ValueObjects;
namespace Shogi.BackEnd.Domains.ValueObjects.Pieces;
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;
namespace Shogi.Domain.ValueObjects;
using System.Numerics;
using Shogi.BackEnd.Domains.ValueObjects.Movement;
using Shogi.BackEnd.Domains.ValueObjects.Pieces;
using Shogi.BackEnd.Domains.YetToBeAssimilatedIntoDDD;
using Path = Shogi.BackEnd.Domains.ValueObjects.Movement.Path;
namespace Shogi.BackEnd.Domains.ValueObjects.Rules;
/// <summary>
/// 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 position = path.Step + piecePosition;
if (path.Distance == YetToBeAssimilatedIntoDDD.Pathing.Distance.MultiStep)
if (path.Distance == Distance.MultiStep)
{
while (position.IsInsideBoardBoundary())
@@ -340,7 +345,7 @@ public sealed class ShogiBoard(BoardState initialState)
else
{
var multiStepPaths = matchingPaths
.Where(path => path.Distance == YetToBeAssimilatedIntoDDD.Pathing.Distance.MultiStep)
.Where(path => path.Distance == Distance.MultiStep)
.ToArray();
if (multiStepPaths.Length == 0)
{
@@ -371,7 +376,7 @@ public sealed class ShogiBoard(BoardState initialState)
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;
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.EntityFrameworkCore;
namespace Shogi.Api.Identity;
namespace Shogi.BackEnd.Identity;
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : IdentityDbContext<ShogiUser>(options)
{

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