diff --git a/.gitignore b/.gitignore index 832cf47..d3310fc 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ obj *.user /Shogi.Database/Shogi.Database.dbmdl /Shogi.Database/Shogi.Database.jfm +/Shogi/appsettings.Development.json diff --git a/BoardRules/BoardRules.cs b/BoardRules/BoardRules.cs new file mode 100644 index 0000000..6b98a7b --- /dev/null +++ b/BoardRules/BoardRules.cs @@ -0,0 +1,120 @@ +//using System.Drawing; +//using System.Numerics; + +//namespace BoardRules; + +//public static class PieceMoves +//{ +// public static readonly ICollection GoldGeneralMoves = +// [ +// new(-1, 1), +// new(0, 1), +// new(1, 1), +// new(-1, 0), +// new(1, 0), +// new(0, -1) +// ]; + +// public static readonly ICollection PawnMoves = [new(0, 1)]; + +// public static readonly ICollection KnightMoves = [new(-1, 2), new(1, 2)]; + +// public static readonly ICollection 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 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 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 MoveSet { get; } +// public ICollection PromotedMoveSet { get; } + +// /// +// /// The starting positions for this type of piece on the board. There could be one or many. +// /// +// public ICollection StartingPositions { get; } +//} + +//public class GoldGeneral : IPiece +//{ +// public string Name => nameof(GoldGeneral); +// public ICollection MoveSet => PieceMoves.GoldGeneralMoves; +// public ICollection PromotedMoveSet => PieceMoves.GoldGeneralMoves; + +// public ICollection StartingPositions => [new(3, 0), new(5, 0), new(4, 1)]; +//} + +//public class Pawn : IPiece +//{ +// public string Name => nameof(Pawn); +// public ICollection MoveSet => PieceMoves.PawnMoves; +// public ICollection PromotedMoveSet => PieceMoves.GoldGeneralMoves; +//} + +//public class Knight : IPiece +//{ +// public string Name => nameof(Knight); +// public ICollection MoveSet => PieceMoves.KnightMoves; +// public ICollection PromotedMoveSet => PieceMoves.GoldGeneralMoves; +//} + +//public class Bishop : IPiece +//{ +// public string Name => nameof(Bishop); +// public ICollection MoveSet => PieceMoves.BishopMoves; +// public ICollection PromotedMoveSet => PieceMoves.BishopMoves; +//} + +//public class Luke +//{ +// public void Yep() +// { +// var board = new BoardRules() +// .WithSize(9, 9) +// .AddPiece(new Pawn()) +// } +//} \ No newline at end of file diff --git a/BoardRules/BoardRules.csproj b/BoardRules/BoardRules.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/BoardRules/BoardRules.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/README.md b/README.md index cd0cb24..121322a 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file + * 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. \ No newline at end of file diff --git a/Shogi.Api/Extensions/ContractsExtensions.cs b/Shogi.Api/Extensions/ContractsExtensions.cs deleted file mode 100644 index 71321e0..0000000 --- a/Shogi.Api/Extensions/ContractsExtensions.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Shogi.Contracts.Types; -using System.Collections.ObjectModel; - -namespace Shogi.Api.Extensions; - -public static class ContractsExtensions -{ - public static WhichPlayer ToContract(this Domain.ValueObjects.WhichPlayer player) - { - return player switch - { - Domain.ValueObjects.WhichPlayer.Player1 => WhichPlayer.Player1, - Domain.ValueObjects.WhichPlayer.Player2 => WhichPlayer.Player2, - _ => throw new NotImplementedException(), - }; - } - - public static WhichPiece ToContract(this Domain.ValueObjects.WhichPiece piece) - { - return piece switch - { - Domain.ValueObjects.WhichPiece.King => WhichPiece.King, - Domain.ValueObjects.WhichPiece.GoldGeneral => WhichPiece.GoldGeneral, - Domain.ValueObjects.WhichPiece.SilverGeneral => WhichPiece.SilverGeneral, - Domain.ValueObjects.WhichPiece.Bishop => WhichPiece.Bishop, - Domain.ValueObjects.WhichPiece.Rook => WhichPiece.Rook, - Domain.ValueObjects.WhichPiece.Knight => WhichPiece.Knight, - Domain.ValueObjects.WhichPiece.Lance => WhichPiece.Lance, - Domain.ValueObjects.WhichPiece.Pawn => WhichPiece.Pawn, - _ => throw new NotImplementedException(), - }; - } - - public static Piece ToContract(this Domain.ValueObjects.Piece piece) => new() - { - IsPromoted = piece.IsPromoted, - Owner = piece.Owner.ToContract(), - WhichPiece = piece.WhichPiece.ToContract() - }; - - public static IReadOnlyList ToContract(this List pieces) - { - return pieces - .Select(ToContract) - .ToList() - .AsReadOnly(); - } - - public static Dictionary ToContract(this ReadOnlyDictionary 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(), - }; - } -} diff --git a/Shogi.Api/Program.cs b/Shogi.Api/Program.cs deleted file mode 100644 index 673199a..0000000 --- a/Shogi.Api/Program.cs +++ /dev/null @@ -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() ?? 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(); -builder.Services.AddTransient(); -builder.Services.AddTransient(); -builder.Services.AddTransient(); -builder.Services.AddHttpClient(); -builder.Services.Configure(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(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"); - -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(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(options => - { - options.SignIn.RequireConfirmedEmail = true; - options.User.RequireUniqueEmail = true; - }) - .AddEntityFrameworkStores(); - - builder.Services.ConfigureApplicationCookie(options => - { - options.SlidingExpiration = true; - options.ExpireTimeSpan = TimeSpan.FromDays(3); - }); - -} \ No newline at end of file diff --git a/Shogi.Api/Properties/launchSettings.json b/Shogi.Api/Properties/launchSettings.json deleted file mode 100644 index 0851a5a..0000000 --- a/Shogi.Api/Properties/launchSettings.json +++ /dev/null @@ -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 - } - } -} \ No newline at end of file diff --git a/Shogi.Api/Shogi.Api.csproj b/Shogi.Api/Shogi.Api.csproj deleted file mode 100644 index 59214e2..0000000 --- a/Shogi.Api/Shogi.Api.csproj +++ /dev/null @@ -1,45 +0,0 @@ - - - - net8.0 - true - 5 - enable - False - False - enable - 973a1f5f-ef25-4f1c-a24d-b0fc7d016ab8 - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - diff --git a/Shogi.Api/appsettings.Development.json b/Shogi.Api/appsettings.Development.json deleted file mode 100644 index 47aef72..0000000 --- a/Shogi.Api/appsettings.Development.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - }, - "ApiKeys": { - "BrevoEmailService": "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" - }, - "TestUserPassword": "I'mAToysRUsK1d" -} diff --git a/Shogi.Contracts/Shogi.Contracts.csproj b/Shogi.Contracts/Shogi.Contracts.csproj deleted file mode 100644 index 7b85818..0000000 --- a/Shogi.Contracts/Shogi.Contracts.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - net8.0 - true - 5 - enable - False - Shogi Service Models - Contains DTOs use for http requests to Shogi backend services. - - - - - - - diff --git a/Shogi.Contracts/ShogiApiJsonSerializerSettings.cs b/Shogi.Contracts/ShogiApiJsonSerializerSettings.cs deleted file mode 100644 index 89277f8..0000000 --- a/Shogi.Contracts/ShogiApiJsonSerializerSettings.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Text.Json; - -namespace Shogi.Contracts; - -public class ShogiApiJsonSerializerSettings -{ - public readonly static JsonSerializerOptions SystemTextJsonSerializerOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = true, - }; -} diff --git a/Shogi.Contracts/Types/Piece.cs b/Shogi.Contracts/Types/Piece.cs deleted file mode 100644 index 3c7b335..0000000 --- a/Shogi.Contracts/Types/Piece.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Shogi.Contracts.Types -{ - public class Piece - { - public bool IsPromoted { get; set; } - public WhichPiece WhichPiece { get; set; } - public WhichPlayer Owner { get; set; } - } -} diff --git a/Shogi.Database/FirstTimeSetup.sql b/Shogi.Database/FirstTimeSetup.sql index 5dfe63b..b61353c 100644 --- a/Shogi.Database/FirstTimeSetup.sql +++ b/Shogi.Database/FirstTimeSetup.sql @@ -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 */ \ No newline at end of file diff --git a/Shogi.Domain/Shogi.Domain.csproj b/Shogi.Domain/Shogi.Domain.csproj deleted file mode 100644 index deb29af..0000000 --- a/Shogi.Domain/Shogi.Domain.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - net8.0 - disable - enable - - - - - - - - - - - - - - diff --git a/Shogi.Domain/ValueObjects/Bishop.cs b/Shogi.Domain/ValueObjects/Bishop.cs deleted file mode 100644 index 7f7d315..0000000 --- a/Shogi.Domain/ValueObjects/Bishop.cs +++ /dev/null @@ -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 BishopPaths = new(new List(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 PromotedBishopPaths = new(new List(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 Player2Paths = - BishopPaths - .Select(p => p.Invert()) - .ToList() - .AsReadOnly(); - - public static readonly ReadOnlyCollection Player2PromotedPaths = - PromotedBishopPaths - .Select(p => p.Invert()) - .ToList() - .AsReadOnly(); - - public Bishop(WhichPlayer owner, bool isPromoted = false) - : base(WhichPiece.Bishop, owner, isPromoted) - { - } - - public override IEnumerable MoveSet => IsPromoted ? PromotedBishopPaths : BishopPaths; - } -} diff --git a/Shogi.Domain/ValueObjects/Knight.cs b/Shogi.Domain/ValueObjects/Knight.cs deleted file mode 100644 index d54cb98..0000000 --- a/Shogi.Domain/ValueObjects/Knight.cs +++ /dev/null @@ -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 Player1Paths = new(new List(2) - { - new Path(Direction.KnightLeft), - new Path(Direction.KnightRight) - }); - - public static readonly ReadOnlyCollection Player2Paths = - Player1Paths - .Select(p => p.Invert()) - .ToList() - .AsReadOnly(); - - public Knight(WhichPlayer owner, bool isPromoted = false) - : base(WhichPiece.Knight, owner, isPromoted) - { - } - - public override ReadOnlyCollection MoveSet => Owner switch - { - WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths, - WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths, - _ => throw new NotImplementedException(), - }; - } -} diff --git a/Shogi.Domain/ValueObjects/Lance.cs b/Shogi.Domain/ValueObjects/Lance.cs deleted file mode 100644 index 690a77c..0000000 --- a/Shogi.Domain/ValueObjects/Lance.cs +++ /dev/null @@ -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 Player1Paths = new(new List(1) - { - new Path(Direction.Forward, Distance.MultiStep), - }); - - public static readonly ReadOnlyCollection Player2Paths = - Player1Paths - .Select(p => p.Invert()) - .ToList() - .AsReadOnly(); - - public Lance(WhichPlayer owner, bool isPromoted = false) - : base(WhichPiece.Lance, owner, isPromoted) - { - } - - public override ReadOnlyCollection MoveSet => Owner switch - { - WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths, - WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths, - _ => throw new NotImplementedException(), - }; - } -} diff --git a/Shogi.Domain/ValueObjects/MoveResult.cs b/Shogi.Domain/ValueObjects/MoveResult.cs deleted file mode 100644 index f66140f..0000000 --- a/Shogi.Domain/ValueObjects/MoveResult.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Shogi.Domain.ValueObjects -{ - public record MoveResult(bool IsSuccess, string Reason = "") - { - } -} diff --git a/Shogi.Domain/ValueObjects/Pawn.cs b/Shogi.Domain/ValueObjects/Pawn.cs deleted file mode 100644 index 6494f74..0000000 --- a/Shogi.Domain/ValueObjects/Pawn.cs +++ /dev/null @@ -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 Player1Paths = new(new List(1) - { - new Path(Direction.Forward) - }); - - public static readonly ReadOnlyCollection Player2Paths = - Player1Paths - .Select(p => p.Invert()) - .ToList() - .AsReadOnly(); - - public Pawn(WhichPlayer owner, bool isPromoted = false) - : base(WhichPiece.Pawn, owner, isPromoted) - { - } - - public override ReadOnlyCollection MoveSet => Owner switch - { - WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths, - WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths, - _ => throw new NotImplementedException(), - }; - } -} diff --git a/Shogi.Domain/ValueObjects/Piece.cs b/Shogi.Domain/ValueObjects/Piece.cs deleted file mode 100644 index 51826d8..0000000 --- a/Shogi.Domain/ValueObjects/Piece.cs +++ /dev/null @@ -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 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; - - /// - /// Prep the piece for capture by changing ownership and demoting. - /// - public void Capture(WhichPlayer newOwner) - { - Owner = newOwner; - IsPromoted = false; - } - - /// - /// 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. - /// - /// - /// - /// An empty list if the piece cannot legally traverse from start to end. Otherwise, a list of positions. - public IEnumerable GetPathFromStartToEnd(Vector2 start, Vector2 end) - { - var steps = new List(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 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; - } - } -} diff --git a/Shogi.Domain/ValueObjects/SilverGeneral.cs b/Shogi.Domain/ValueObjects/SilverGeneral.cs deleted file mode 100644 index a37becb..0000000 --- a/Shogi.Domain/ValueObjects/SilverGeneral.cs +++ /dev/null @@ -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 Player1Paths = new(new List(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 Player2Paths = - Player1Paths - .Select(p => p.Invert()) - .ToList() - .AsReadOnly(); - - public SilverGeneral(WhichPlayer owner, bool isPromoted = false) - : base(WhichPiece.SilverGeneral, owner, isPromoted) - { - } - - public override ReadOnlyCollection MoveSet => Owner switch - { - WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths, - WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths, - _ => throw new NotImplementedException(), - }; - } -} diff --git a/Shogi.Domain/ValueObjects/StandardRules.cs b/Shogi.Domain/ValueObjects/StandardRules.cs deleted file mode 100644 index 911520a..0000000 --- a/Shogi.Domain/ValueObjects/StandardRules.cs +++ /dev/null @@ -1,166 +0,0 @@ -using Shogi.Domain.YetToBeAssimilatedIntoDDD; -using BoardTile = System.Collections.Generic.KeyValuePair; - -namespace Shogi.Domain.ValueObjects -{ - internal class StandardRules - { - private readonly BoardState boardState; - - internal StandardRules(BoardState board) - { - boardState = board; - } - - /// - /// Determines if the last move put the player who moved in check. - /// - /// - /// 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. - /// - 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 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); - } - } -} diff --git a/Shogi.Domain/ValueObjects/WhichPlayer.cs b/Shogi.Domain/ValueObjects/WhichPlayer.cs deleted file mode 100644 index 796c095..0000000 --- a/Shogi.Domain/ValueObjects/WhichPlayer.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Shogi.Domain.ValueObjects -{ - public enum WhichPlayer - { - Player1, - Player2 - } -} diff --git a/Shogi.Domain/YetToBeAssimilatedIntoDDD/DomainExtensions.cs b/Shogi.Domain/YetToBeAssimilatedIntoDDD/DomainExtensions.cs deleted file mode 100644 index dd5c72a..0000000 --- a/Shogi.Domain/YetToBeAssimilatedIntoDDD/DomainExtensions.cs +++ /dev/null @@ -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); - } - } -} diff --git a/Shogi.Domain/YetToBeAssimilatedIntoDDD/Notation.cs b/Shogi.Domain/YetToBeAssimilatedIntoDDD/Notation.cs deleted file mode 100644 index f75600e..0000000 --- a/Shogi.Domain/YetToBeAssimilatedIntoDDD/Notation.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text.RegularExpressions; - -namespace Shogi.Domain.YetToBeAssimilatedIntoDDD -{ - public static class Notation - { - private static readonly string BoardNotationRegex = @"(?[A-I])(?[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}"); - } - } -} diff --git a/Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Direction.cs b/Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Direction.cs deleted file mode 100644 index 24dc12e..0000000 --- a/Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Direction.cs +++ /dev/null @@ -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); - } diff --git a/Shogi.Domain/YetToBeAssimilatedIntoDDD/ReadMe.md b/Shogi.Domain/YetToBeAssimilatedIntoDDD/ReadMe.md deleted file mode 100644 index 51bfb0f..0000000 --- a/Shogi.Domain/YetToBeAssimilatedIntoDDD/ReadMe.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/Shogi.UI/.config/dotnet-tools.json b/Shogi.UI/.config/dotnet-tools.json deleted file mode 100644 index 4b64fb7..0000000 --- a/Shogi.UI/.config/dotnet-tools.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "microsoft.dotnet-msidentity": { - "version": "1.0.5", - "commands": [ - "dotnet-msidentity" - ] - } - } -} \ No newline at end of file diff --git a/Shogi.UI/Identity/CookieAuthenticationStateProvider.cs b/Shogi.UI/Identity/CookieAuthenticationStateProvider.cs deleted file mode 100644 index 648de91..0000000 --- a/Shogi.UI/Identity/CookieAuthenticationStateProvider.cs +++ /dev/null @@ -1,279 +0,0 @@ -namespace Shogi.UI.Identity; - -using Microsoft.AspNetCore.Components.Authorization; -using System.Net.Http; -using System.Net.Http.Json; -using System.Security.Claims; -using System.Text; -using System.Text.Json; - -/// -/// Handles state for cookie-based auth. -/// -public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IAccountManagement -{ - /// - /// Map the JavaScript-formatted properties to C#-formatted classes. - /// - private readonly JsonSerializerOptions jsonSerializerOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }; - - /// - /// Special auth client. - /// - private readonly HttpClient _httpClient; - - /// - /// Authentication state. - /// - private bool _authenticated = false; - - /// - /// Default principal for anonymous (not authenticated) users. - /// - private readonly ClaimsPrincipal Unauthenticated = - new(new ClaimsIdentity()); - - /// - /// Create a new instance of the auth provider. - /// - /// Factory to retrieve auth client. - public CookieAuthenticationStateProvider(IHttpClientFactory httpClientFactory) - => _httpClient = httpClientFactory.CreateClient("Auth"); - - /// - /// Register a new user. - /// - /// The user's email address. - /// The user's password. - /// The result serialized to a . - /// - public async Task 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(); - 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 - }; - } - - /// - /// User login. - /// - /// The user's email address. - /// The user's password. - /// The result of the login request serialized to a . - public async Task 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."] - }; - } - - /// - /// Get authentication state. - /// - /// - /// Called by Blazor anytime and authentication-based decision needs to be made, then cached - /// until the changed state notification is raised. - /// - /// The authentication state asynchronous request. - public override async Task 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(userJson, jsonSerializerOptions); - - if (userInfo != null) - { - // in our system name and email are the same - var claims = new List - { - 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(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 CheckAuthenticatedAsync() - { - await GetAuthenticationStateAsync(); - return _authenticated; - } - - /// - /// Ask for an email to be sent which contains a reset code. This reset code is used during - /// - /// Do not surface errors from this to users which may tell bad actors if emails do or do not exist in the system. - public async Task RequestPasswordReset(string email) - { - return await _httpClient.PostAsJsonAsync("forgotPassword", new { email }); - } - - public async Task ChangePassword(string email, string resetCode, string newPassword) - { - var body = new - { - email, - resetCode, - newPassword - }; - var response = await _httpClient.PostAsJsonAsync("resetPassword", body); - if (response.IsSuccessStatusCode) - { - return new FormResult { Succeeded = true }; - } - else - { - return new FormResult - { - Succeeded = false, - ErrorList = [await response.Content.ReadAsStringAsync()] - }; - } - } - - public class RoleClaim - { - public string? Issuer { get; set; } - public string? OriginalIssuer { get; set; } - public string? Type { get; set; } - public string? Value { get; set; } - public string? ValueType { get; set; } - } -} diff --git a/Shogi.UI/Identity/CookieMessageHandler.cs b/Shogi.UI/Identity/CookieMessageHandler.cs deleted file mode 100644 index 3d78081..0000000 --- a/Shogi.UI/Identity/CookieMessageHandler.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.AspNetCore.Components.WebAssembly.Http; - -namespace Shogi.UI.Identity; - -public class CookieCredentialsMessageHandler : DelegatingHandler -{ - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include); - request.Headers.Add("X-Requested-With", ["XMLHttpRequest"]); - - return base.SendAsync(request, cancellationToken); - } -} diff --git a/Shogi.UI/Pages/Home/HomePage.razor b/Shogi.UI/Pages/Home/HomePage.razor deleted file mode 100644 index 4185bc4..0000000 --- a/Shogi.UI/Pages/Home/HomePage.razor +++ /dev/null @@ -1,48 +0,0 @@ -@page "/" - -@using Shogi.Contracts.Types -@using System.Net.WebSockets -@using System.Text - -
-

What is Shogi?

-

Shogi is a two-player strategy game where each player simultaneously protects their king while capturing their opponent's.

-

Players take turns, moving one piece each turn until check-mate is achieved.

- -

How to Play

- -

Setup

-

Arrange the board so it looks like this. Take note of the Rook and Bishop positions for each player.

- - - -

Pieces and Movement

-

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.

-

A tile may only hold one piece and, except for the Knight, pieces may never move through each other.

-

Should your piece enter the tile of an opponent's piece, you must stop there and capture the opponent's piece.

- - -

Promotion

-

The furthest three ranks from your starting position is an area called the promotion zone. A piece may promote at the end of the turn when it moves in to, out of, or within the promotion zone.

-

Promoting changes the move-set available to the peice, and a piece must promote 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.

-

All pieces may promote except for the Gold General and King.

- - -

Capturing and the Hand

-

The King and "Check"

-

Victory

-
- -@code { - private string activeSessionName = string.Empty; - private Task OnLoginChanged() - { - StateHasChanged(); - return Task.CompletedTask; - } - private void OnChangeSession(SessionMetadata s) - { - activeSessionName = s.SessionId.ToString(); - StateHasChanged(); - } -} diff --git a/Shogi.UI/Pages/Home/HomePage.razor.css b/Shogi.UI/Pages/Home/HomePage.razor.css deleted file mode 100644 index 7fdd8dc..0000000 --- a/Shogi.UI/Pages/Home/HomePage.razor.css +++ /dev/null @@ -1,7 +0,0 @@ -.shogi { - background-color: var(--primary-color); - color: white; - padding: 1rem; - padding-top: 0; - overflow: auto; -} diff --git a/Shogi.UI/Pages/Home/_Imports.razor b/Shogi.UI/Pages/Home/_Imports.razor deleted file mode 100644 index 0a497b0..0000000 --- a/Shogi.UI/Pages/Home/_Imports.razor +++ /dev/null @@ -1 +0,0 @@ -@using Shogi.UI.Pages.Home.VisualAids \ No newline at end of file diff --git a/Shogi.UI/Pages/Identity/LoginPage.razor b/Shogi.UI/Pages/Identity/LoginPage.razor deleted file mode 100644 index 24b1bbe..0000000 --- a/Shogi.UI/Pages/Identity/LoginPage.razor +++ /dev/null @@ -1,76 +0,0 @@ -@page "/login" -@inject IAccountManagement Acct -@inject NavigationManager navigator - -
-

Login

- -
- - -
You're logged in as @context.User.Identity?.Name.
-
- - @if (errorList.Length > 0) - { -
    - @foreach (var error in errorList) - { -
  • @error
  • - } -
- } - - - - - - - - Reset password - - -
-
-
- -
- -@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; - } - } -} \ No newline at end of file diff --git a/Shogi.UI/Pages/Identity/LoginPage.razor.css b/Shogi.UI/Pages/Identity/LoginPage.razor.css deleted file mode 100644 index 42ff5c7..0000000 --- a/Shogi.UI/Pages/Identity/LoginPage.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -main { - padding: 1rem; -} - -.LoginForm { - grid-area: form; - display: inline-grid; - grid-template-areas: - "errors errors" - "emailLabel emailControl" - "passLabel passControl" - ". resetLink" - "button button"; - gap: 0.5rem 3rem; -} - - .LoginForm .Errors { - color: darkred; - background-color: var(--foregroundColor); - } diff --git a/Shogi.UI/Pages/Play/GameBoard/EmptyGameBoard.razor b/Shogi.UI/Pages/Play/GameBoard/EmptyGameBoard.razor deleted file mode 100644 index 0473868..0000000 --- a/Shogi.UI/Pages/Play/GameBoard/EmptyGameBoard.razor +++ /dev/null @@ -1,6 +0,0 @@ -@using Contracts.Types; - - - -@code { -} diff --git a/Shogi.UI/Pages/Play/GameBrowserEntry.razor b/Shogi.UI/Pages/Play/GameBrowserEntry.razor deleted file mode 100644 index 389db93..0000000 --- a/Shogi.UI/Pages/Play/GameBrowserEntry.razor +++ /dev/null @@ -1,75 +0,0 @@ -@using Shogi.Contracts.Types - -@inject ShogiApi Api - - - @if (showDeletePrompt) - { - -
- @if (showDeleteError) - { -

An error occurred.

-
- - } - else - { -

Do you wish to delete this session?

-
- - - } -
- - } - - - @if (string.IsNullOrEmpty(Session.Player2)) - { - 1 / 2 - } - else - { - Full - } - - @if (context.User.Identity?.Name == Session.Player1) - { - - - - } - - - -@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; - } - } -} diff --git a/Shogi.UI/Pages/SearchPage.razor b/Shogi.UI/Pages/SearchPage.razor deleted file mode 100644 index 9d3afd5..0000000 --- a/Shogi.UI/Pages/SearchPage.razor +++ /dev/null @@ -1,11 +0,0 @@ -@page "/search" - -
-

Find Sessions

- - -
- -@code { - -} diff --git a/Shogi.UI/Pages/SearchPage.razor.css b/Shogi.UI/Pages/SearchPage.razor.css deleted file mode 100644 index f14027d..0000000 --- a/Shogi.UI/Pages/SearchPage.razor.css +++ /dev/null @@ -1,3 +0,0 @@ -.SearchPage { - padding: 0 0.5rem; -} diff --git a/Shogi.UI/Program.cs b/Shogi.UI/Program.cs deleted file mode 100644 index 9dd0486..0000000 --- a/Shogi.UI/Program.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.AspNetCore.Components.Web; -using Microsoft.AspNetCore.Components.WebAssembly.Hosting; -using Microsoft.AspNetCore.ResponseCompression; -using Shogi.UI; -using Shogi.UI.Identity; -using Shogi.UI.Shared; -using System.Text.Json; - -var builder = WebAssemblyHostBuilder.CreateDefault(args); -builder.RootComponents.Add("#app"); -builder.RootComponents.Add("head::after"); -builder.Logging.AddConfiguration( - builder.Configuration.GetSection("Logging")); -ConfigureDependencies(builder.Services, builder.Configuration); - -builder.Services.AddResponseCompression(options => -{ - options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/octet-stream"]); -}); - -await builder.Build().RunAsync(); - -static void ConfigureDependencies(IServiceCollection services, IConfiguration configuration) -{ - /** - * Why two HTTP clients? - * See https://docs.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/additional-scenarios?source=recommendations&view=aspnetcore-6.0#unauthenticated-or-unauthorized-web-api-requests-in-an-app-with-a-secure-default-client - */ - var baseUrl = configuration["ShogiApiUrl"]; - if (string.IsNullOrWhiteSpace(baseUrl)) - { - throw new InvalidOperationException("ShogiApiUrl configuration is missing."); - } - - var shogiApiUrl = new Uri(baseUrl, UriKind.Absolute); - - services - .AddTransient() - .AddTransient(); - - // Identity - services - .AddAuthorizationCore(options => options.AddPolicy("Admin", policy => policy.RequireUserName("Hauth@live.com"))) - .AddScoped() - .AddScoped(sp => (IAccountManagement)sp.GetRequiredService()) - .AddHttpClient("Auth", client => client.BaseAddress = shogiApiUrl) // "Auth" is the name expected by the auth library. - .AddHttpMessageHandler(); - - // Network clients - services - .AddHttpClient(client => client.BaseAddress = shogiApiUrl) - .AddHttpMessageHandler(); - services - .AddSingleton(); - - - var serializerOptions = new JsonSerializerOptions - { - WriteIndented = true - }; - services.AddSingleton((sp) => serializerOptions); -} \ No newline at end of file diff --git a/Shogi.UI/Properties/Resources.Designer.cs b/Shogi.UI/Properties/Resources.Designer.cs deleted file mode 100644 index 59e260a..0000000 --- a/Shogi.UI/Properties/Resources.Designer.cs +++ /dev/null @@ -1,63 +0,0 @@ -//------------------------------------------------------------------------------ -// -// 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. -// -//------------------------------------------------------------------------------ - -namespace Shogi.UI.Properties { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // 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() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [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; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - } -} diff --git a/Shogi.UI/Properties/Resources.resx b/Shogi.UI/Properties/Resources.resx deleted file mode 100644 index 4fdb1b6..0000000 --- a/Shogi.UI/Properties/Resources.resx +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 1.3 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - \ No newline at end of file diff --git a/Shogi.UI/Properties/launchSettings.json b/Shogi.UI/Properties/launchSettings.json deleted file mode 100644 index 8c7658c..0000000 --- a/Shogi.UI/Properties/launchSettings.json +++ /dev/null @@ -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 - } - } -} \ No newline at end of file diff --git a/Shogi.UI/Properties/serviceDependencies.json b/Shogi.UI/Properties/serviceDependencies.json deleted file mode 100644 index 44cc45e..0000000 --- a/Shogi.UI/Properties/serviceDependencies.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "dependencies": { - "identityapp1": { - "type": "identityapp", - "dynamicId": null - } - } -} \ No newline at end of file diff --git a/Shogi.UI/Properties/serviceDependencies.local.json b/Shogi.UI/Properties/serviceDependencies.local.json deleted file mode 100644 index 3c85224..0000000 --- a/Shogi.UI/Properties/serviceDependencies.local.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "dependencies": { - "identityapp1": { - "type": "identityapp.default", - "dynamicId": null - } - } -} \ No newline at end of file diff --git a/Shogi.UI/Shared/ShogiApi.cs b/Shogi.UI/Shared/ShogiApi.cs deleted file mode 100644 index 34cb91e..0000000 --- a/Shogi.UI/Shared/ShogiApi.cs +++ /dev/null @@ -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 GetSession(string name) - { - var response = await httpClient.GetAsync(Relative($"Sessions/{name}")); - if (response.IsSuccessStatusCode) - { - return (await response.Content.ReadFromJsonAsync(this.serializerOptions)); - } - return null; - } - - public async Task GetAllSessionsMetadata() - { - var response = await httpClient.GetAsync(Relative("Sessions")); - if (response.IsSuccessStatusCode) - { - return (await response.Content.ReadFromJsonAsync(this.serializerOptions))!; - } - return []; - } - - /// - /// Returns false if the move was not accepted by the server. - /// - public async Task Move(Guid sessionName, MovePieceCommand command) - { - var response = await httpClient.PatchAsync(Relative($"Sessions/{sessionName}/Move"), JsonContent.Create(command)); - return response.IsSuccessStatusCode; - } - - public async Task PostSession() - { - var response = await httpClient.PostAsync(Relative("Sessions"), null); - var sessionId = response.IsSuccessStatusCode ? await response.Content.ReadAsStringAsync() : null; - return sessionId; - } - - public Task PatchJoinGame(string name) - { - return httpClient.PatchAsync(Relative($"Sessions/{name}/Join"), null); - } - - public Task DeleteSession(Guid sessionId) - { - return httpClient.DeleteAsync(Relative($"Sessions/{sessionId}")); - } - - private static Uri Relative(string path) => new(path, UriKind.Relative); -} diff --git a/Shogi.UI/Shogi.UI.csproj b/Shogi.UI/Shogi.UI.csproj deleted file mode 100644 index 2478355..0000000 --- a/Shogi.UI/Shogi.UI.csproj +++ /dev/null @@ -1,63 +0,0 @@ - - - - net8.0 - enable - enable - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - True - True - Resources.resx - - - - - - ResXFileCodeGenerator - Resources.Designer.cs - - - - diff --git a/Shogi.UI/wwwroot/appsettings.Development.json b/Shogi.UI/wwwroot/appsettings.Development.json deleted file mode 100644 index 2c63c08..0000000 --- a/Shogi.UI/wwwroot/appsettings.Development.json +++ /dev/null @@ -1,2 +0,0 @@ -{ -} diff --git a/Shogi.UI/wwwroot/appsettings.json b/Shogi.UI/wwwroot/appsettings.json deleted file mode 100644 index 12680a0..0000000 --- a/Shogi.UI/wwwroot/appsettings.json +++ /dev/null @@ -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/" -} \ No newline at end of file diff --git a/Shogi.UI/wwwroot/index.html b/Shogi.UI/wwwroot/index.html deleted file mode 100644 index 65953da..0000000 --- a/Shogi.UI/wwwroot/index.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - Shogi.UI - - - - - - - - -
- -
- An unhandled error has occurred. - Reload - 🗙 -
- - - - - diff --git a/Shogi.sln b/Shogi.sln index bba4cc1..2e8993e 100644 --- a/Shogi.sln +++ b/Shogi.sln @@ -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} diff --git a/Shogi.Api/.config/dotnet-tools.json b/Shogi/.config/dotnet-tools.json similarity index 90% rename from Shogi.Api/.config/dotnet-tools.json rename to Shogi/.config/dotnet-tools.json index 50a0457..cf20ad1 100644 --- a/Shogi.Api/.config/dotnet-tools.json +++ b/Shogi/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "6.0.5", + "version": "10.0.2", "commands": [ "dotnet-ef" ] diff --git a/Shogi.Api/ApiKeys.cs b/Shogi/ApiKeys.cs similarity index 78% rename from Shogi.Api/ApiKeys.cs rename to Shogi/ApiKeys.cs index f19377d..3c7891e 100644 --- a/Shogi.Api/ApiKeys.cs +++ b/Shogi/ApiKeys.cs @@ -1,4 +1,4 @@ -namespace Shogi.Api; +namespace Shogi; public class ApiKeys { diff --git a/Shogi.Api/Application/GameHub.cs b/Shogi/BackEnd/Application/GameHub.cs similarity index 91% rename from Shogi.Api/Application/GameHub.cs rename to Shogi/BackEnd/Application/GameHub.cs index 4eb4b2d..f03e7fc 100644 --- a/Shogi.Api/Application/GameHub.cs +++ b/Shogi/BackEnd/Application/GameHub.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.SignalR; -namespace Shogi.Api.Application; +namespace Shogi.BackEnd.Application; /// /// Used to receive signals from connected clients. diff --git a/Shogi.Api/Application/GameHubContext.cs b/Shogi/BackEnd/Application/GameHubContext.cs similarity index 92% rename from Shogi.Api/Application/GameHubContext.cs rename to Shogi/BackEnd/Application/GameHubContext.cs index cbcead0..218d416 100644 --- a/Shogi.Api/Application/GameHubContext.cs +++ b/Shogi/BackEnd/Application/GameHubContext.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.SignalR; -namespace Shogi.Api.Application; +namespace Shogi.BackEnd.Application; /// /// Used to send signals to connected clients. diff --git a/Shogi.Api/Application/ShogiApplication.cs b/Shogi/BackEnd/Application/ShogiApplication.cs similarity index 89% rename from Shogi.Api/Application/ShogiApplication.cs rename to Shogi/BackEnd/Application/ShogiApplication.cs index 792e18a..5aa1f61 100644 --- a/Shogi.Api/Application/ShogiApplication.cs +++ b/Shogi/BackEnd/Application/ShogiApplication.cs @@ -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 MovePiece(string playerId, string sessionId, MovePieceCommand command) + public async Task MovePiece(string playerId, string sessionId, Types.MovePieceCommand command) { var session = await this.ReadSession(sessionId); - if (session == null) + if (session is null) { return new NotFoundResult(); } @@ -105,7 +104,7 @@ public class ShogiApplication( public async Task 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)) { diff --git a/Shogi.Api/Controllers/AccountController.cs b/Shogi/BackEnd/Controllers/AccountController.cs similarity index 54% rename from Shogi.Api/Controllers/AccountController.cs rename to Shogi/BackEnd/Controllers/AccountController.cs index a744f09..1de4cf5 100644 --- a/Shogi.Api/Controllers/AccountController.cs +++ b/Shogi/BackEnd/Controllers/AccountController.cs @@ -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 signInManager, UserManager 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 Logout([FromBody] object empty) + [Authorize("Admin")] + [HttpDelete("TestAccount")] + public async Task DeleteTestAccounts() { - // https://learn.microsoft.com/aspnet/core/blazor/security/webassembly/standalone-with-identity#antiforgery-support - if (empty is not null) - { - await signInManager.SignOutAsync(); + var testUsers = new[] { "aat-account", "aat-account-2" }; - return this.Ok(); + 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.Unauthorized(); + return this.NoContent(); } - [HttpGet("/roles")] + [HttpPost("Login")] + [AllowAnonymous] + public async Task 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 Logout() + { + await signInManager.SignOutAsync(); + return Redirect("/"); + } + + [HttpGet("/backend/roles")] public IActionResult GetRoles() { if (this.User.Identity is not null && this.User.Identity.IsAuthenticated) diff --git a/Shogi.Api/Controllers/Extentions.cs b/Shogi/BackEnd/Controllers/Extentions.cs similarity index 85% rename from Shogi.Api/Controllers/Extentions.cs rename to Shogi/BackEnd/Controllers/Extentions.cs index eb828cb..4d6668e 100644 --- a/Shogi.Api/Controllers/Extentions.cs +++ b/Shogi/BackEnd/Controllers/Extentions.cs @@ -1,6 +1,6 @@ using System.Security.Claims; -namespace Shogi.Api.Controllers; +namespace Shogi.BackEnd.Controllers; public static class Extentions { diff --git a/Shogi.Api/Controllers/MyIdentityApiEndpointRouteBuilderExtensions.cs b/Shogi/BackEnd/Controllers/MyIdentityApiEndpointRouteBuilderExtensions.cs similarity index 99% rename from Shogi.Api/Controllers/MyIdentityApiEndpointRouteBuilderExtensions.cs rename to Shogi/BackEnd/Controllers/MyIdentityApiEndpointRouteBuilderExtensions.cs index 85fe6e8..493521a 100644 --- a/Shogi.Api/Controllers/MyIdentityApiEndpointRouteBuilderExtensions.cs +++ b/Shogi/BackEnd/Controllers/MyIdentityApiEndpointRouteBuilderExtensions.cs @@ -15,7 +15,7 @@ using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; -namespace Shogi.Api.Controllers; +namespace Shogi.BackEnd.Controllers; /// /// Provides extension methods for 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 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}'."); diff --git a/Shogi.Api/Controllers/SessionsController.cs b/Shogi/BackEnd/Controllers/SessionsController.cs similarity index 69% rename from Shogi.Api/Controllers/SessionsController.cs rename to Shogi/BackEnd/Controllers/SessionsController.cs index 95b3a2b..91076cb 100644 --- a/Shogi.Api/Controllers/SessionsController.cs +++ b/Shogi/BackEnd/Controllers/SessionsController.cs @@ -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> 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.ToContract(), - Player2Hand = session.Board.BoardState.Player2Hand.ToContract(), - 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 }; } diff --git a/Shogi.Domain/Aggregates/Session.cs b/Shogi/BackEnd/Domains/Aggregates/Session.cs similarity index 86% rename from Shogi.Domain/Aggregates/Session.cs rename to Shogi/BackEnd/Domains/Aggregates/Session.cs index 4c76fcf..4cc1cb7 100644 --- a/Shogi.Domain/Aggregates/Session.cs +++ b/Shogi/BackEnd/Domains/Aggregates/Session.cs @@ -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) { diff --git a/Shogi.Domain/ValueObjects/BoardState.cs b/Shogi/BackEnd/Domains/ValueObjects/BoardState.cs similarity index 96% rename from Shogi.Domain/ValueObjects/BoardState.cs rename to Shogi/BackEnd/Domains/ValueObjects/BoardState.cs index 03a51a6..ff0b982 100644 --- a/Shogi.Domain/ValueObjects/BoardState.cs +++ b/Shogi/BackEnd/Domains/ValueObjects/BoardState.cs @@ -1,8 +1,11 @@ -using System.Collections.ObjectModel; -using Shogi.Domain.YetToBeAssimilatedIntoDDD; -using BoardTile = System.Collections.Generic.KeyValuePair; +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; -namespace Shogi.Domain.ValueObjects; +namespace Shogi.BackEnd.Domains.ValueObjects; public class BoardState { diff --git a/Shogi.Domain/ValueObjects/Enums.cs b/Shogi/BackEnd/Domains/ValueObjects/Enums.cs similarity index 83% rename from Shogi.Domain/ValueObjects/Enums.cs rename to Shogi/BackEnd/Domains/ValueObjects/Enums.cs index 0bc6749..a7781d0 100644 --- a/Shogi.Domain/ValueObjects/Enums.cs +++ b/Shogi/BackEnd/Domains/ValueObjects/Enums.cs @@ -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 +} diff --git a/Shogi/BackEnd/Domains/ValueObjects/Movement/Direction.cs b/Shogi/BackEnd/Domains/ValueObjects/Movement/Direction.cs new file mode 100644 index 0000000..f1a3427 --- /dev/null +++ b/Shogi/BackEnd/Domains/ValueObjects/Movement/Direction.cs @@ -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); +} diff --git a/Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Distance.cs b/Shogi/BackEnd/Domains/ValueObjects/Movement/Distance.cs similarity index 80% rename from Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Distance.cs rename to Shogi/BackEnd/Domains/ValueObjects/Movement/Distance.cs index 915f3af..1ad6fdf 100644 --- a/Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Distance.cs +++ b/Shogi/BackEnd/Domains/ValueObjects/Movement/Distance.cs @@ -1,4 +1,4 @@ -namespace Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing; +namespace Shogi.BackEnd.Domains.ValueObjects.Movement; public enum Distance { @@ -10,4 +10,4 @@ public enum Distance /// Signifies that a piece can move multiple tiles/positions in a single move. /// MultiStep -} \ No newline at end of file +} diff --git a/Shogi.Domain/ValueObjects/Move.cs b/Shogi/BackEnd/Domains/ValueObjects/Movement/Move.cs similarity index 86% rename from Shogi.Domain/ValueObjects/Move.cs rename to Shogi/BackEnd/Domains/ValueObjects/Movement/Move.cs index a9d7e45..b5cd595 100644 --- a/Shogi.Domain/ValueObjects/Move.cs +++ b/Shogi/BackEnd/Domains/ValueObjects/Movement/Move.cs @@ -1,4 +1,6 @@ -namespace Shogi.Domain.ValueObjects; +using System.Numerics; + +namespace Shogi.BackEnd.Domains.ValueObjects.Movement; /// /// Represents a single piece being moved by a player from to . diff --git a/Shogi/BackEnd/Domains/ValueObjects/Movement/MoveResult.cs b/Shogi/BackEnd/Domains/ValueObjects/Movement/MoveResult.cs new file mode 100644 index 0000000..06362dc --- /dev/null +++ b/Shogi/BackEnd/Domains/ValueObjects/Movement/MoveResult.cs @@ -0,0 +1,5 @@ +namespace Shogi.BackEnd.Domains.ValueObjects.Movement; + +public record MoveResult(bool IsSuccess, string Reason = "") +{ +} diff --git a/Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Path.cs b/Shogi/BackEnd/Domains/ValueObjects/Movement/Path.cs similarity index 79% rename from Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Path.cs rename to Shogi/BackEnd/Domains/ValueObjects/Movement/Path.cs index ffd22f3..65868e7 100644 --- a/Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Path.cs +++ b/Shogi/BackEnd/Domains/ValueObjects/Movement/Path.cs @@ -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,8 +18,8 @@ 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); -} \ No newline at end of file +} diff --git a/Shogi/BackEnd/Domains/ValueObjects/Pieces/Bishop.cs b/Shogi/BackEnd/Domains/ValueObjects/Pieces/Bishop.cs new file mode 100644 index 0000000..3751e4d --- /dev/null +++ b/Shogi/BackEnd/Domains/ValueObjects/Pieces/Bishop.cs @@ -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 BishopPaths = new(new List(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 PromotedBishopPaths = new(new List(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 Player2Paths = + BishopPaths + .Select(p => p.Invert()) + .ToList() + .AsReadOnly(); + + public static readonly ReadOnlyCollection Player2PromotedPaths = + PromotedBishopPaths + .Select(p => p.Invert()) + .ToList() + .AsReadOnly(); + + public Bishop(WhichPlayer owner, bool isPromoted = false) + : base(WhichPiece.Bishop, owner, isPromoted) + { + } + + public override IEnumerable MoveSet => IsPromoted ? PromotedBishopPaths : BishopPaths; +} diff --git a/Shogi.Domain/ValueObjects/GoldGeneral.cs b/Shogi/BackEnd/Domains/ValueObjects/Pieces/GoldGeneral.cs similarity index 81% rename from Shogi.Domain/ValueObjects/GoldGeneral.cs rename to Shogi/BackEnd/Domains/ValueObjects/Pieces/GoldGeneral.cs index e5b0628..c6f35fe 100644 --- a/Shogi.Domain/ValueObjects/GoldGeneral.cs +++ b/Shogi/BackEnd/Domains/ValueObjects/Pieces/GoldGeneral.cs @@ -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 { diff --git a/Shogi.Domain/ValueObjects/King.cs b/Shogi/BackEnd/Domains/ValueObjects/Pieces/King.cs similarity index 82% rename from Shogi.Domain/ValueObjects/King.cs rename to Shogi/BackEnd/Domains/ValueObjects/Pieces/King.cs index d7cf66c..e0e40c2 100644 --- a/Shogi.Domain/ValueObjects/King.cs +++ b/Shogi/BackEnd/Domains/ValueObjects/Pieces/King.cs @@ -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 { diff --git a/Shogi/BackEnd/Domains/ValueObjects/Pieces/Knight.cs b/Shogi/BackEnd/Domains/ValueObjects/Pieces/Knight.cs new file mode 100644 index 0000000..f0c35a9 --- /dev/null +++ b/Shogi/BackEnd/Domains/ValueObjects/Pieces/Knight.cs @@ -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 Player1Paths = new(new List(2) + { + new Path(Direction.KnightLeft), + new Path(Direction.KnightRight) + }); + + public static readonly ReadOnlyCollection Player2Paths = + Player1Paths + .Select(p => p.Invert()) + .ToList() + .AsReadOnly(); + + public Knight(WhichPlayer owner, bool isPromoted = false) + : base(WhichPiece.Knight, owner, isPromoted) + { + } + + public override ReadOnlyCollection MoveSet => Owner switch + { + WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths, + WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths, + _ => throw new NotImplementedException(), + }; +} diff --git a/Shogi/BackEnd/Domains/ValueObjects/Pieces/Lance.cs b/Shogi/BackEnd/Domains/ValueObjects/Pieces/Lance.cs new file mode 100644 index 0000000..3d1be3d --- /dev/null +++ b/Shogi/BackEnd/Domains/ValueObjects/Pieces/Lance.cs @@ -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 Player1Paths = new(new List(1) + { + new Path(Direction.Forward, Distance.MultiStep), + }); + + public static readonly ReadOnlyCollection Player2Paths = + Player1Paths + .Select(p => p.Invert()) + .ToList() + .AsReadOnly(); + + public Lance(WhichPlayer owner, bool isPromoted = false) + : base(WhichPiece.Lance, owner, isPromoted) + { + } + + public override ReadOnlyCollection MoveSet => Owner switch + { + WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths, + WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths, + _ => throw new NotImplementedException(), + }; +} diff --git a/Shogi/BackEnd/Domains/ValueObjects/Pieces/Pawn.cs b/Shogi/BackEnd/Domains/ValueObjects/Pieces/Pawn.cs new file mode 100644 index 0000000..5f9de3a --- /dev/null +++ b/Shogi/BackEnd/Domains/ValueObjects/Pieces/Pawn.cs @@ -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 Player1Paths = new(new List(1) + { + new Path(Direction.Forward) + }); + + public static readonly ReadOnlyCollection Player2Paths = + Player1Paths + .Select(p => p.Invert()) + .ToList() + .AsReadOnly(); + + public Pawn(WhichPlayer owner, bool isPromoted = false) + : base(WhichPiece.Pawn, owner, isPromoted) + { + } + + public override ReadOnlyCollection MoveSet => Owner switch + { + WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths, + WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths, + _ => throw new NotImplementedException(), + }; +} diff --git a/Shogi/BackEnd/Domains/ValueObjects/Pieces/Piece.cs b/Shogi/BackEnd/Domains/ValueObjects/Pieces/Piece.cs new file mode 100644 index 0000000..c18daf3 --- /dev/null +++ b/Shogi/BackEnd/Domains/ValueObjects/Pieces/Piece.cs @@ -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 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; + + /// + /// Prep the piece for capture by changing ownership and demoting. + /// + public void Capture(WhichPlayer newOwner) + { + Owner = newOwner; + IsPromoted = false; + } + + /// + /// 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. + /// + /// + /// + /// An empty list if the piece cannot legally traverse from start to end. Otherwise, a list of positions. + public IEnumerable GetPathFromStartToEnd(Vector2 start, Vector2 end) + { + var steps = new List(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 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; + } +} diff --git a/Shogi.Domain/ValueObjects/Rook.cs b/Shogi/BackEnd/Domains/ValueObjects/Pieces/Rook.cs similarity index 90% rename from Shogi.Domain/ValueObjects/Rook.cs rename to Shogi/BackEnd/Domains/ValueObjects/Pieces/Rook.cs index 45ab9d6..5a65f12 100644 --- a/Shogi.Domain/ValueObjects/Rook.cs +++ b/Shogi/BackEnd/Domains/ValueObjects/Pieces/Rook.cs @@ -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 { diff --git a/Shogi/BackEnd/Domains/ValueObjects/Pieces/SilverGeneral.cs b/Shogi/BackEnd/Domains/ValueObjects/Pieces/SilverGeneral.cs new file mode 100644 index 0000000..9d23a03 --- /dev/null +++ b/Shogi/BackEnd/Domains/ValueObjects/Pieces/SilverGeneral.cs @@ -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 Player1Paths = new(new List(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 Player2Paths = + Player1Paths + .Select(p => p.Invert()) + .ToList() + .AsReadOnly(); + + public SilverGeneral(WhichPlayer owner, bool isPromoted = false) + : base(WhichPiece.SilverGeneral, owner, isPromoted) + { + } + + public override ReadOnlyCollection MoveSet => Owner switch + { + WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths, + WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths, + _ => throw new NotImplementedException(), + }; +} diff --git a/Shogi.Domain/ValueObjects/ShogiBoard.cs b/Shogi/BackEnd/Domains/ValueObjects/Rules/ShogiBoard.cs similarity index 96% rename from Shogi.Domain/ValueObjects/ShogiBoard.cs rename to Shogi/BackEnd/Domains/ValueObjects/Rules/ShogiBoard.cs index e0bb92e..0d6e944 100644 --- a/Shogi.Domain/ValueObjects/ShogiBoard.cs +++ b/Shogi/BackEnd/Domains/ValueObjects/Rules/ShogiBoard.cs @@ -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; /// /// Facilitates Shogi board state transitions, cognisant of Shogi rules. @@ -247,7 +252,7 @@ public sealed class ShogiBoard(BoardState initialState) { var list = new List(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 GetPositionsAlongPath(Vector2 from, Vector2 to, YetToBeAssimilatedIntoDDD.Pathing.Path path) + private static IEnumerable 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) diff --git a/Shogi/BackEnd/Domains/ValueObjects/Rules/StandardRules.cs b/Shogi/BackEnd/Domains/ValueObjects/Rules/StandardRules.cs new file mode 100644 index 0000000..fc47ceb --- /dev/null +++ b/Shogi/BackEnd/Domains/ValueObjects/Rules/StandardRules.cs @@ -0,0 +1,166 @@ +using Shogi.BackEnd.Domains.YetToBeAssimilatedIntoDDD; +using System.Numerics; +using BoardTile = System.Collections.Generic.KeyValuePair; + +namespace Shogi.BackEnd.Domains.ValueObjects.Rules; + +internal class StandardRules +{ + private readonly BoardState boardState; + + internal StandardRules(BoardState board) + { + boardState = board; + } + + /// + /// Determines if the last move put the player who moved in check. + /// + /// + /// 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. + /// + 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 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); + } +} diff --git a/Shogi/BackEnd/Domains/YetToBeAssimilatedIntoDDD/DomainExtensions.cs b/Shogi/BackEnd/Domains/YetToBeAssimilatedIntoDDD/DomainExtensions.cs new file mode 100644 index 0000000..5860191 --- /dev/null +++ b/Shogi/BackEnd/Domains/YetToBeAssimilatedIntoDDD/DomainExtensions.cs @@ -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); + } +} diff --git a/Shogi/BackEnd/Domains/YetToBeAssimilatedIntoDDD/Notation.cs b/Shogi/BackEnd/Domains/YetToBeAssimilatedIntoDDD/Notation.cs new file mode 100644 index 0000000..3fee9cd --- /dev/null +++ b/Shogi/BackEnd/Domains/YetToBeAssimilatedIntoDDD/Notation.cs @@ -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 = @"(?[A-I])(?[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}"); + } +} diff --git a/Shogi/BackEnd/Extensions/ContractsExtensions.cs b/Shogi/BackEnd/Extensions/ContractsExtensions.cs new file mode 100644 index 0000000..fffba31 --- /dev/null +++ b/Shogi/BackEnd/Extensions/ContractsExtensions.cs @@ -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 ToContract(this List pieces) + { + return pieces + .Select(ToContract) + .ToList() + .AsReadOnly(); + } + + public static Dictionary ToContract(this ReadOnlyDictionary 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(), + }; + } +} diff --git a/Shogi.Api/Identity/ApplicationDbContext.cs b/Shogi/BackEnd/Identity/ApplicationDbContext.cs similarity index 86% rename from Shogi.Api/Identity/ApplicationDbContext.cs rename to Shogi/BackEnd/Identity/ApplicationDbContext.cs index 13be2bb..c5dcd86 100644 --- a/Shogi.Api/Identity/ApplicationDbContext.cs +++ b/Shogi/BackEnd/Identity/ApplicationDbContext.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; -namespace Shogi.Api.Identity; +namespace Shogi.BackEnd.Identity; public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext(options) { diff --git a/Shogi/BackEnd/Identity/ServerAccountManager.cs b/Shogi/BackEnd/Identity/ServerAccountManager.cs new file mode 100644 index 0000000..b543fda --- /dev/null +++ b/Shogi/BackEnd/Identity/ServerAccountManager.cs @@ -0,0 +1,72 @@ +namespace Shogi.BackEnd.Identity; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.WebUtilities; +using Shogi.FrontEnd.Client; +using System.Text; +using System.Text.Encodings.Web; + +public class ServerAccountManager( + UserManager userManager, + IHttpContextAccessor httpContextAccessor, + IEmailSender emailSender) : IAccountManagement +{ + public async Task RegisterAsync(string email, string password) + { + var user = new ShogiUser { UserName = email, Email = email }; + var result = await userManager.CreateAsync(user, password); + if (result.Succeeded) + { + var userId = await userManager.GetUserIdAsync(user); + var code = await userManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + var request = httpContextAccessor.HttpContext?.Request; + var host = request?.Host.Value ?? "localhost"; + var scheme = request?.Scheme ?? "https"; + var callbackUrl = $"{scheme}://{host}/backend/confirmEmail?userId={userId}&code={code}"; + + await emailSender.SendEmailAsync(email, "Confirm your email", + $"Please confirm your account by clicking here."); + + return new FormResult { Succeeded = true }; + } + return new FormResult { Succeeded = false, ErrorList = result.Errors.Select(e => e.Description).ToArray() }; + } + + public Task LoginAsync(string email, string password) + { + throw new NotSupportedException("Login must be performed via Form POST to /backend/Account/Login in Server-Side Rendering."); + } + + public Task LogoutAsync() + { + // Logout should be performed via Form POST or Link to /backend/Account/Logout. + return Task.CompletedTask; + } + + public Task CheckAuthenticatedAsync() + { + return Task.FromResult(httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false); + } + + public async Task RequestPasswordReset(string email) + { + var user = await userManager.FindByEmailAsync(email); + if (user != null) + { + // Generate token and send email logic would go here. + } + return new HttpResponseMessage(System.Net.HttpStatusCode.OK); + } + + public async Task ChangePassword(string email, string resetCode, string newPassword) + { + var user = await userManager.FindByEmailAsync(email); + if (user == null) return new FormResult { Succeeded = false, ErrorList = ["User not found"] }; + var result = await userManager.ResetPasswordAsync(user, resetCode, newPassword); + return new FormResult { Succeeded = result.Succeeded, ErrorList = result.Errors.Select(e => e.Description).ToArray() }; + } +} diff --git a/Shogi.Api/Identity/ShogiUser.cs b/Shogi/BackEnd/Identity/ShogiUser.cs similarity index 71% rename from Shogi.Api/Identity/ShogiUser.cs rename to Shogi/BackEnd/Identity/ShogiUser.cs index 22eb31d..4bbe2da 100644 --- a/Shogi.Api/Identity/ShogiUser.cs +++ b/Shogi/BackEnd/Identity/ShogiUser.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Identity; -namespace Shogi.Api.Identity; +namespace Shogi.BackEnd.Identity; public class ShogiUser : IdentityUser { diff --git a/Shogi.Api/Migrations/20240816002834_InitialCreate.Designer.cs b/Shogi/BackEnd/Migrations/20240816002834_InitialCreate.Designer.cs similarity index 96% rename from Shogi.Api/Migrations/20240816002834_InitialCreate.Designer.cs rename to Shogi/BackEnd/Migrations/20240816002834_InitialCreate.Designer.cs index 24c2203..cf87bba 100644 --- a/Shogi.Api/Migrations/20240816002834_InitialCreate.Designer.cs +++ b/Shogi/BackEnd/Migrations/20240816002834_InitialCreate.Designer.cs @@ -5,11 +5,11 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Shogi.Api.Identity; +using Shogi.BackEnd.Identity; #nullable disable -namespace Shogi.Api.Migrations +namespace Shogi.BackEnd.Migrations { [DbContext(typeof(ApplicationDbContext))] [Migration("20240816002834_InitialCreate")] @@ -158,7 +158,7 @@ namespace Shogi.Api.Migrations b.ToTable("AspNetUserTokens", (string)null); }); - modelBuilder.Entity("Shogi.Api.Models.User", b => + modelBuilder.Entity("Shogi.Models.User", b => { b.Property("Id") .HasColumnType("nvarchar(450)"); @@ -234,7 +234,7 @@ namespace Shogi.Api.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("Shogi.Api.Models.User", null) + b.HasOne("Shogi.Models.User", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -243,7 +243,7 @@ namespace Shogi.Api.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("Shogi.Api.Models.User", null) + b.HasOne("Shogi.Models.User", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -258,7 +258,7 @@ namespace Shogi.Api.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Shogi.Api.Models.User", null) + b.HasOne("Shogi.Models.User", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -267,7 +267,7 @@ namespace Shogi.Api.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("Shogi.Api.Models.User", null) + b.HasOne("Shogi.Models.User", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/Shogi.Api/Migrations/20240816002834_InitialCreate.cs b/Shogi/BackEnd/Migrations/20240816002834_InitialCreate.cs similarity index 99% rename from Shogi.Api/Migrations/20240816002834_InitialCreate.cs rename to Shogi/BackEnd/Migrations/20240816002834_InitialCreate.cs index 9ddf303..e405349 100644 --- a/Shogi.Api/Migrations/20240816002834_InitialCreate.cs +++ b/Shogi/BackEnd/Migrations/20240816002834_InitialCreate.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace Shogi.Api.Migrations +namespace Shogi.BackEnd.Migrations { /// public partial class InitialCreate : Migration diff --git a/Shogi.Api/Migrations/ApplicationDbContextModelSnapshot.cs b/Shogi/BackEnd/Migrations/ApplicationDbContextModelSnapshot.cs similarity index 96% rename from Shogi.Api/Migrations/ApplicationDbContextModelSnapshot.cs rename to Shogi/BackEnd/Migrations/ApplicationDbContextModelSnapshot.cs index bc2412f..8ae44b3 100644 --- a/Shogi.Api/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Shogi/BackEnd/Migrations/ApplicationDbContextModelSnapshot.cs @@ -4,11 +4,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Shogi.Api.Identity; +using Shogi.BackEnd.Identity; #nullable disable -namespace Shogi.Api.Migrations +namespace Shogi.BackEnd.Migrations { [DbContext(typeof(ApplicationDbContext))] partial class ApplicationDbContextModelSnapshot : ModelSnapshot @@ -155,7 +155,7 @@ namespace Shogi.Api.Migrations b.ToTable("AspNetUserTokens", (string)null); }); - modelBuilder.Entity("Shogi.Api.Models.User", b => + modelBuilder.Entity("Shogi.Models.User", b => { b.Property("Id") .HasColumnType("nvarchar(450)"); @@ -231,7 +231,7 @@ namespace Shogi.Api.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("Shogi.Api.Models.User", null) + b.HasOne("Shogi.Models.User", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -240,7 +240,7 @@ namespace Shogi.Api.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("Shogi.Api.Models.User", null) + b.HasOne("Shogi.Models.User", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -255,7 +255,7 @@ namespace Shogi.Api.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Shogi.Api.Models.User", null) + b.HasOne("Shogi.Models.User", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -264,7 +264,7 @@ namespace Shogi.Api.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("Shogi.Api.Models.User", null) + b.HasOne("Shogi.Models.User", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/Shogi.Api/Repositories/CouchModels/BoardStateDocument.cs b/Shogi/BackEnd/Repositories/CouchModels/BoardStateDocument.cs similarity index 100% rename from Shogi.Api/Repositories/CouchModels/BoardStateDocument.cs rename to Shogi/BackEnd/Repositories/CouchModels/BoardStateDocument.cs diff --git a/Shogi.Api/Repositories/CouchModels/CouchCreatedResult.cs b/Shogi/BackEnd/Repositories/CouchModels/CouchCreatedResult.cs similarity index 100% rename from Shogi.Api/Repositories/CouchModels/CouchCreatedResult.cs rename to Shogi/BackEnd/Repositories/CouchModels/CouchCreatedResult.cs diff --git a/Shogi.Api/Repositories/CouchModels/CouchDocument.cs b/Shogi/BackEnd/Repositories/CouchModels/CouchDocument.cs similarity index 100% rename from Shogi.Api/Repositories/CouchModels/CouchDocument.cs rename to Shogi/BackEnd/Repositories/CouchModels/CouchDocument.cs diff --git a/Shogi.Api/Repositories/CouchModels/CouchFindResult.cs b/Shogi/BackEnd/Repositories/CouchModels/CouchFindResult.cs similarity index 100% rename from Shogi.Api/Repositories/CouchModels/CouchFindResult.cs rename to Shogi/BackEnd/Repositories/CouchModels/CouchFindResult.cs diff --git a/Shogi.Api/Repositories/CouchModels/CouchViewResult.cs b/Shogi/BackEnd/Repositories/CouchModels/CouchViewResult.cs similarity index 100% rename from Shogi.Api/Repositories/CouchModels/CouchViewResult.cs rename to Shogi/BackEnd/Repositories/CouchModels/CouchViewResult.cs diff --git a/Shogi.Api/Repositories/CouchModels/Move.cs b/Shogi/BackEnd/Repositories/CouchModels/Move.cs similarity index 100% rename from Shogi.Api/Repositories/CouchModels/Move.cs rename to Shogi/BackEnd/Repositories/CouchModels/Move.cs diff --git a/Shogi.Api/Repositories/CouchModels/Piece.cs b/Shogi/BackEnd/Repositories/CouchModels/Piece.cs similarity index 100% rename from Shogi.Api/Repositories/CouchModels/Piece.cs rename to Shogi/BackEnd/Repositories/CouchModels/Piece.cs diff --git a/Shogi.Api/Repositories/CouchModels/SessionDocument.cs b/Shogi/BackEnd/Repositories/CouchModels/SessionDocument.cs similarity index 100% rename from Shogi.Api/Repositories/CouchModels/SessionDocument.cs rename to Shogi/BackEnd/Repositories/CouchModels/SessionDocument.cs diff --git a/Shogi.Api/Repositories/CouchModels/UserDocument.cs b/Shogi/BackEnd/Repositories/CouchModels/UserDocument.cs similarity index 100% rename from Shogi.Api/Repositories/CouchModels/UserDocument.cs rename to Shogi/BackEnd/Repositories/CouchModels/UserDocument.cs diff --git a/Shogi.Api/Repositories/CouchModels/WhichDocumentType.cs b/Shogi/BackEnd/Repositories/CouchModels/WhichDocumentType.cs similarity index 100% rename from Shogi.Api/Repositories/CouchModels/WhichDocumentType.cs rename to Shogi/BackEnd/Repositories/CouchModels/WhichDocumentType.cs diff --git a/Shogi.Api/Repositories/Dto/MoveDto.cs b/Shogi/BackEnd/Repositories/Dto/MoveDto.cs similarity index 68% rename from Shogi.Api/Repositories/Dto/MoveDto.cs rename to Shogi/BackEnd/Repositories/Dto/MoveDto.cs index 2c6b086..02809e5 100644 --- a/Shogi.Api/Repositories/Dto/MoveDto.cs +++ b/Shogi/BackEnd/Repositories/Dto/MoveDto.cs @@ -1,6 +1,6 @@ -using Shogi.Domain.ValueObjects; +using Shogi.BackEnd.Domains.ValueObjects; -namespace Shogi.Api.Repositories.Dto; +namespace Shogi.BackEnd.Repositories.Dto; /// /// Useful with Dapper to read from database. diff --git a/Shogi.Api/Repositories/Dto/SessionDto.cs b/Shogi/BackEnd/Repositories/Dto/SessionDto.cs similarity index 67% rename from Shogi.Api/Repositories/Dto/SessionDto.cs rename to Shogi/BackEnd/Repositories/Dto/SessionDto.cs index bec4145..406e7d2 100644 --- a/Shogi.Api/Repositories/Dto/SessionDto.cs +++ b/Shogi/BackEnd/Repositories/Dto/SessionDto.cs @@ -1,4 +1,4 @@ -namespace Shogi.Api.Repositories.Dto; +namespace Shogi.BackEnd.Repositories.Dto; public readonly record struct SessionDto(string Id, string Player1Id, string Player2Id) { diff --git a/Shogi.Api/Repositories/EmailSender.cs b/Shogi/BackEnd/Repositories/EmailSender.cs similarity index 97% rename from Shogi.Api/Repositories/EmailSender.cs rename to Shogi/BackEnd/Repositories/EmailSender.cs index 84c1e69..4e14364 100644 --- a/Shogi.Api/Repositories/EmailSender.cs +++ b/Shogi/BackEnd/Repositories/EmailSender.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Options; using System.Net.Http.Headers; using System.Text.Json; -namespace Shogi.Api.Repositories; +namespace Shogi.BackEnd.Repositories; // https://app-smtp.brevo.com/real-time diff --git a/Shogi.Api/Repositories/GameboardRepository.cs b/Shogi/BackEnd/Repositories/GameboardRepository.cs similarity index 100% rename from Shogi.Api/Repositories/GameboardRepository.cs rename to Shogi/BackEnd/Repositories/GameboardRepository.cs diff --git a/Shogi.Api/Repositories/QueryRepository.cs b/Shogi/BackEnd/Repositories/QueryRepository.cs similarity index 90% rename from Shogi.Api/Repositories/QueryRepository.cs rename to Shogi/BackEnd/Repositories/QueryRepository.cs index 6b489fb..c8a163f 100644 --- a/Shogi.Api/Repositories/QueryRepository.cs +++ b/Shogi/BackEnd/Repositories/QueryRepository.cs @@ -1,9 +1,9 @@ using Dapper; -using Shogi.Api.Repositories.Dto; +using Shogi.BackEnd.Repositories.Dto; using System.Data; using System.Data.SqlClient; -namespace Shogi.Api.Repositories; +namespace Shogi.BackEnd.Repositories; public class QueryRepository(IConfiguration configuration) { diff --git a/Shogi.Api/Repositories/SessionRepository.cs b/Shogi/BackEnd/Repositories/SessionRepository.cs similarity index 90% rename from Shogi.Api/Repositories/SessionRepository.cs rename to Shogi/BackEnd/Repositories/SessionRepository.cs index 7ccc6e5..65c8d11 100644 --- a/Shogi.Api/Repositories/SessionRepository.cs +++ b/Shogi/BackEnd/Repositories/SessionRepository.cs @@ -1,11 +1,10 @@ using Dapper; -using Shogi.Api.Repositories.Dto; -using Shogi.Contracts.Api.Commands; -using Shogi.Domain.Aggregates; +using Shogi.BackEnd.Repositories.Dto; +using Shogi.BackEnd.Domains.Aggregates; using System.Data; using System.Data.SqlClient; -namespace Shogi.Api.Repositories; +namespace Shogi.BackEnd.Repositories; public class SessionRepository(IConfiguration configuration) { @@ -53,7 +52,7 @@ public class SessionRepository(IConfiguration configuration) return new(sessionDtos.First(), moveDtos); } - public async Task CreateMove(string sessionId, MovePieceCommand command) + public async Task CreateMove(string sessionId, Types.MovePieceCommand command) { using var connection = new SqlConnection(this.connectionString); await connection.ExecuteAsync( diff --git a/Shogi.Contracts/Types/BoardState.cs b/Shogi/BackEnd/Types/BoardState.cs similarity index 83% rename from Shogi.Contracts/Types/BoardState.cs rename to Shogi/BackEnd/Types/BoardState.cs index ef9b987..ef834db 100644 --- a/Shogi.Contracts/Types/BoardState.cs +++ b/Shogi/BackEnd/Types/BoardState.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; - -namespace Shogi.Contracts.Types; +namespace Shogi.BackEnd.Types; public class BoardState { diff --git a/Shogi.Contracts/Api/Commands/MovePieceCommand.cs b/Shogi/BackEnd/Types/MovePieceCommand.cs similarity index 95% rename from Shogi.Contracts/Api/Commands/MovePieceCommand.cs rename to Shogi/BackEnd/Types/MovePieceCommand.cs index 741fd69..a8ab1b2 100644 --- a/Shogi.Contracts/Api/Commands/MovePieceCommand.cs +++ b/Shogi/BackEnd/Types/MovePieceCommand.cs @@ -1,9 +1,7 @@ -using Shogi.Contracts.Types; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Text.RegularExpressions; -namespace Shogi.Contracts.Api.Commands; +namespace Shogi.BackEnd.Types; public partial class MovePieceCommand : IValidatableObject { diff --git a/Shogi/BackEnd/Types/Piece.cs b/Shogi/BackEnd/Types/Piece.cs new file mode 100644 index 0000000..2c7c41c --- /dev/null +++ b/Shogi/BackEnd/Types/Piece.cs @@ -0,0 +1,8 @@ +namespace Shogi.BackEnd.Types; + +public class Piece +{ + public bool IsPromoted { get; set; } + public WhichPiece WhichPiece { get; set; } + public WhichPlayer Owner { get; set; } +} diff --git a/Shogi.Contracts/Types/Session.cs b/Shogi/BackEnd/Types/Session.cs similarity index 74% rename from Shogi.Contracts/Types/Session.cs rename to Shogi/BackEnd/Types/Session.cs index bc67642..e418097 100644 --- a/Shogi.Contracts/Types/Session.cs +++ b/Shogi/BackEnd/Types/Session.cs @@ -1,6 +1,4 @@ -using System; - -namespace Shogi.Contracts.Types; +namespace Shogi.BackEnd.Types; public class Session { @@ -16,5 +14,5 @@ public class Session public Guid SessionId { get; set; } - public BoardState BoardState { get; set; } + public BoardState BoardState { get; set; } = new(); } diff --git a/Shogi.Contracts/Types/SessionMetadata.cs b/Shogi/BackEnd/Types/SessionMetadata.cs similarity index 77% rename from Shogi.Contracts/Types/SessionMetadata.cs rename to Shogi/BackEnd/Types/SessionMetadata.cs index 8b3c2b3..e242ea7 100644 --- a/Shogi.Contracts/Types/SessionMetadata.cs +++ b/Shogi/BackEnd/Types/SessionMetadata.cs @@ -1,6 +1,4 @@ -using System; - -namespace Shogi.Contracts.Types; +namespace Shogi.BackEnd.Types; public class SessionMetadata { diff --git a/Shogi.Contracts/Types/WhichPiece.cs b/Shogi/BackEnd/Types/WhichPiece.cs similarity index 74% rename from Shogi.Contracts/Types/WhichPiece.cs rename to Shogi/BackEnd/Types/WhichPiece.cs index 2a13589..5b174a6 100644 --- a/Shogi.Contracts/Types/WhichPiece.cs +++ b/Shogi/BackEnd/Types/WhichPiece.cs @@ -1,4 +1,4 @@ -namespace Shogi.Contracts.Types; +namespace Shogi.BackEnd.Types; public enum WhichPiece { diff --git a/Shogi.Contracts/Types/WhichPerspective.cs b/Shogi/BackEnd/Types/WhichPlayer.cs similarity index 57% rename from Shogi.Contracts/Types/WhichPerspective.cs rename to Shogi/BackEnd/Types/WhichPlayer.cs index 6016326..6b52b93 100644 --- a/Shogi.Contracts/Types/WhichPerspective.cs +++ b/Shogi/BackEnd/Types/WhichPlayer.cs @@ -1,4 +1,4 @@ -namespace Shogi.Contracts.Types; +namespace Shogi.BackEnd.Types; public enum WhichPlayer { diff --git a/Shogi.UI/Identity/FormResult.cs b/Shogi/FrontEnd/Client/FormResult.cs similarity index 89% rename from Shogi.UI/Identity/FormResult.cs rename to Shogi/FrontEnd/Client/FormResult.cs index c2bf8ac..6cc9253 100644 --- a/Shogi.UI/Identity/FormResult.cs +++ b/Shogi/FrontEnd/Client/FormResult.cs @@ -1,4 +1,4 @@ -namespace Shogi.UI.Identity; +namespace Shogi.FrontEnd.Client; public class FormResult { diff --git a/Shogi.UI/Shared/GameHubNode.cs b/Shogi/FrontEnd/Client/GameHubNode.cs similarity index 64% rename from Shogi.UI/Shared/GameHubNode.cs rename to Shogi/FrontEnd/Client/GameHubNode.cs index 6d78365..899bd73 100644 --- a/Shogi.UI/Shared/GameHubNode.cs +++ b/Shogi/FrontEnd/Client/GameHubNode.cs @@ -1,28 +1,19 @@ -using Microsoft.AspNetCore.SignalR.Client; -using Shogi.UI.Identity; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR.Client; -namespace Shogi.UI.Shared; +namespace Shogi.FrontEnd.Client; public class GameHubNode : IAsyncDisposable { private readonly HubConnection hubConnection; - public GameHubNode(IConfiguration configuration) + public GameHubNode(NavigationManager navigationManager) { - var baseUrl = configuration["ShogiApiUrl"]; - if (string.IsNullOrWhiteSpace(baseUrl)) - { - throw new InvalidOperationException("ShogiApiUrl configuration is missing."); - } + var hubUrl = navigationManager.ToAbsoluteUri("/gamehub"); this.hubConnection = new HubConnectionBuilder() - .WithUrl(new Uri(new Uri(baseUrl, UriKind.Absolute), "gamehub"), options => - { - options.HttpMessageHandlerFactory = handler => new CookieCredentialsMessageHandler { InnerHandler = handler }; - options.SkipNegotiation = true; - options.Transports = Microsoft.AspNetCore.Http.Connections.HttpTransportType.WebSockets; - }) - .Build(); + .WithUrl(hubUrl) + .Build(); this.hubConnection.Closed += this.HubConnection_Closed; } diff --git a/Shogi.UI/Identity/IAccountManagement.cs b/Shogi/FrontEnd/Client/IAccountManagement.cs similarity index 96% rename from Shogi.UI/Identity/IAccountManagement.cs rename to Shogi/FrontEnd/Client/IAccountManagement.cs index 3f92acc..76bb5d8 100644 --- a/Shogi.UI/Identity/IAccountManagement.cs +++ b/Shogi/FrontEnd/Client/IAccountManagement.cs @@ -1,4 +1,4 @@ -namespace Shogi.UI.Identity; +namespace Shogi.FrontEnd.Client; /// /// Account management services. diff --git a/Shogi.UI/Shared/LocalStorage.cs b/Shogi/FrontEnd/Client/LocalStorage.cs similarity index 97% rename from Shogi.UI/Shared/LocalStorage.cs rename to Shogi/FrontEnd/Client/LocalStorage.cs index 5acbc3b..8439036 100644 --- a/Shogi.UI/Shared/LocalStorage.cs +++ b/Shogi/FrontEnd/Client/LocalStorage.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Shogi.UI.Shared; +namespace Shogi.FrontEnd.Client; public class LocalStorage : ILocalStorage { diff --git a/Shogi/FrontEnd/Client/ShogiService.cs b/Shogi/FrontEnd/Client/ShogiService.cs new file mode 100644 index 0000000..0a2eab5 --- /dev/null +++ b/Shogi/FrontEnd/Client/ShogiService.cs @@ -0,0 +1,111 @@ +using Microsoft.AspNetCore.Identity; +using Shogi.BackEnd.Application; +using Shogi.BackEnd.Extensions; +using Shogi.BackEnd.Identity; +using Shogi.BackEnd.Repositories; +using Shogi.BackEnd.Types; + +namespace Shogi.FrontEnd.Client; + +/// +/// Service layer for Blazor components to access application functionality directly, +/// without going through HTTP. +/// +public class ShogiService( + ShogiApplication application, + SessionRepository sessionRepository, + QueryRepository queryRepository, + UserManager userManager, + GameHubContext gameHubContext) +{ + public async Task CreateSession(string playerId) + { + var sessionId = Guid.NewGuid(); + var session = new BackEnd.Domains.Aggregates.Session(sessionId, playerId); + + try + { + await sessionRepository.CreateSession(session); + return sessionId; + } + catch + { + return null; + } + } + + public async Task GetSession(string sessionId) + { + var domainSession = await application.ReadSession(sessionId); + if (domainSession is null) return null; + + return new Session + { + BoardState = new BoardState + { + Board = domainSession.Board.BoardState.State.ToContract(), + Player1Hand = domainSession.Board.BoardState.Player1Hand.ToContract(), + Player2Hand = domainSession.Board.BoardState.Player2Hand.ToContract(), + PlayerInCheck = domainSession.Board.BoardState.InCheck?.ToContract(), + WhoseTurn = domainSession.Board.BoardState.WhoseTurn.ToContract(), + Victor = domainSession.Board.BoardState.IsCheckmate + ? domainSession.Board.BoardState.InCheck == BackEnd.Domains.ValueObjects.WhichPlayer.Player1 + ? WhichPlayer.Player2 + : WhichPlayer.Player1 + : null + }, + Player1 = GetUsername(domainSession.Player1), + Player2 = GetUsername(domainSession.Player2), + SessionId = domainSession.Id + }; + } + + public async Task GetAllSessionsMetadata(string? playerId) + { + var dtos = await application.ReadAllSessionMetadatas(playerId ?? string.Empty); + return dtos + .Select(dto => new SessionMetadata + { + Player1 = GetUsername(dto.Player1Id), + Player2 = GetUsername(dto.Player2Id), + SessionId = Guid.Parse(dto.Id), + }) + .ToArray(); + } + + public async Task Move(string playerId, Guid sessionId, MovePieceCommand command) + { + var result = await application.MovePiece(playerId, sessionId.ToString(), command); + return result is Microsoft.AspNetCore.Mvc.NoContentResult; + } + + public async Task JoinSession(string sessionId, string playerId) + { + var result = await application.JoinSession(sessionId, playerId); + return result is Microsoft.AspNetCore.Mvc.OkResult; + } + + public async Task DeleteSession(Guid sessionId, string playerId) + { + var (session, _) = await sessionRepository.ReadSessionAndMoves(sessionId.ToString()); + if (!session.HasValue) return true; + + if (session.Value.Player1Id == playerId) + { + await sessionRepository.DeleteSession(sessionId.ToString()); + return true; + } + + return false; + } + + private string GetUsername(string? userId) + { + if (string.IsNullOrEmpty(userId)) + { + return string.Empty; + } + + return userManager.Users.FirstOrDefault(u => u.Id == userId)?.UserName ?? string.Empty; + } +} diff --git a/Shogi.UI/Identity/UserInfo.cs b/Shogi/FrontEnd/Client/UserInfo.cs similarity index 93% rename from Shogi.UI/Identity/UserInfo.cs rename to Shogi/FrontEnd/Client/UserInfo.cs index 01e7809..ff78ef4 100644 --- a/Shogi.UI/Identity/UserInfo.cs +++ b/Shogi/FrontEnd/Client/UserInfo.cs @@ -1,4 +1,4 @@ -namespace Shogi.UI.Identity; +namespace Shogi.FrontEnd.Client; /// /// User info from identity endpoint to establish claims. diff --git a/Shogi/FrontEnd/Components/App.razor b/Shogi/FrontEnd/Components/App.razor new file mode 100644 index 0000000..57d6e1f --- /dev/null +++ b/Shogi/FrontEnd/Components/App.razor @@ -0,0 +1,27 @@ + + + + + + + + Shogi + + + + + + + + + + + + + diff --git a/Shogi.UI/Shared/IconButton.razor b/Shogi/FrontEnd/Components/IconButton.razor similarity index 100% rename from Shogi.UI/Shared/IconButton.razor rename to Shogi/FrontEnd/Components/IconButton.razor diff --git a/Shogi.UI/Shared/IconButton.razor.css b/Shogi/FrontEnd/Components/IconButton.razor.css similarity index 100% rename from Shogi.UI/Shared/IconButton.razor.css rename to Shogi/FrontEnd/Components/IconButton.razor.css diff --git a/Shogi.UI/Shared/Icons/ChevronDownIcon.razor b/Shogi/FrontEnd/Components/Icons/ChevronDownIcon.razor similarity index 100% rename from Shogi.UI/Shared/Icons/ChevronDownIcon.razor rename to Shogi/FrontEnd/Components/Icons/ChevronDownIcon.razor diff --git a/Shogi.UI/Shared/Icons/ChevronUpIcon.razor b/Shogi/FrontEnd/Components/Icons/ChevronUpIcon.razor similarity index 100% rename from Shogi.UI/Shared/Icons/ChevronUpIcon.razor rename to Shogi/FrontEnd/Components/Icons/ChevronUpIcon.razor diff --git a/Shogi.UI/Shared/Icons/TrashCanIcon.razor b/Shogi/FrontEnd/Components/Icons/TrashCanIcon.razor similarity index 100% rename from Shogi.UI/Shared/Icons/TrashCanIcon.razor rename to Shogi/FrontEnd/Components/Icons/TrashCanIcon.razor diff --git a/Shogi.UI/Layout/MainLayout.razor b/Shogi/FrontEnd/Components/Layout/MainLayout.razor similarity index 56% rename from Shogi.UI/Layout/MainLayout.razor rename to Shogi/FrontEnd/Components/Layout/MainLayout.razor index 35169e1..bac1397 100644 --- a/Shogi.UI/Layout/MainLayout.razor +++ b/Shogi/FrontEnd/Components/Layout/MainLayout.razor @@ -1,6 +1,6 @@ @inherits LayoutComponentBase -
+
@Body
diff --git a/Shogi.UI/Layout/MainLayout.razor.css b/Shogi/FrontEnd/Components/Layout/MainLayout.razor.css similarity index 100% rename from Shogi.UI/Layout/MainLayout.razor.css rename to Shogi/FrontEnd/Components/Layout/MainLayout.razor.css diff --git a/Shogi.UI/Layout/NavMenu.razor b/Shogi/FrontEnd/Components/Layout/NavMenu.razor similarity index 73% rename from Shogi.UI/Layout/NavMenu.razor rename to Shogi/FrontEnd/Components/Layout/NavMenu.razor index febff03..d1f400e 100644 --- a/Shogi.UI/Layout/NavMenu.razor +++ b/Shogi/FrontEnd/Components/Layout/NavMenu.razor @@ -1,9 +1,8 @@ @inject NavigationManager navigator -@inject ShogiApi Api +@inject ShogiService Service @* Desktop view *@ @code { + [CascadingParameter] + private Task AuthState { get; set; } = default!; + private bool isExpanded = false; async Task CreateSession() { - var sessionId = await Api.PostSession(); - if (!string.IsNullOrEmpty(sessionId)) + var state = await AuthState; + var userId = state.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + if (userId is null) return; + + var sessionId = await Service.CreateSession(userId); + if (sessionId.HasValue) { - navigator.NavigateTo($"play/{sessionId}"); + navigator.NavigateTo($"play/{sessionId.Value}"); } } diff --git a/Shogi.UI/Layout/NavMenu.razor.css b/Shogi/FrontEnd/Components/Layout/NavMenu.razor.css similarity index 91% rename from Shogi.UI/Layout/NavMenu.razor.css rename to Shogi/FrontEnd/Components/Layout/NavMenu.razor.css index 79590e1..23ef8f3 100644 --- a/Shogi.UI/Layout/NavMenu.razor.css +++ b/Shogi/FrontEnd/Components/Layout/NavMenu.razor.css @@ -1,8 +1,9 @@ .NavMenu { display: flex; align-items: baseline; - gap: 0.75rem; - padding: 0 0.5rem; + justify-content: center; + gap: 2rem; + padding: 0 2rem; } .NavMenu .spacer { diff --git a/Shogi.UI/Pages/FancyErrorPage.razor b/Shogi/FrontEnd/Components/Pages/FancyErrorPage.razor similarity index 100% rename from Shogi.UI/Pages/FancyErrorPage.razor rename to Shogi/FrontEnd/Components/Pages/FancyErrorPage.razor diff --git a/Shogi.UI/Pages/FancyErrorPage.razor.css b/Shogi/FrontEnd/Components/Pages/FancyErrorPage.razor.css similarity index 100% rename from Shogi.UI/Pages/FancyErrorPage.razor.css rename to Shogi/FrontEnd/Components/Pages/FancyErrorPage.razor.css diff --git a/Shogi/FrontEnd/Components/Pages/Home/HomePage.razor b/Shogi/FrontEnd/Components/Pages/Home/HomePage.razor new file mode 100644 index 0000000..5eec767 --- /dev/null +++ b/Shogi/FrontEnd/Components/Pages/Home/HomePage.razor @@ -0,0 +1,81 @@ +@page "/" + +@using System.Net.WebSockets +@using System.Text + +
+ + +
+

What is Shogi?

+

Shogi is a two-player strategy game where each player simultaneously protects their king while capturing their opponent's.

+

Players take turns, moving one piece each turn until check-mate is achieved.

+ +

How to Play

+ +

Setup

+

Arrange the board so it looks like this. Take note of the Rook and Bishop positions for each player.

+ + + +

Pieces and Movement

+

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.

+

A tile may only hold one piece and, except for the Knight, pieces may never move through each other.

+

Should your piece enter the tile of an opponent's piece, you must stop there and capture the opponent's piece.

+ + +

Promotion

+

The furthest three ranks from your starting position is an area called the promotion zone. A piece may promote at the end of the turn when it moves in to, out of, or within the promotion zone.

+

Promoting changes the move-set available to the peice, and a piece must promote 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.

+

All pieces may promote except for the Gold General and King.

+ + +

Capturing and the Hand

+

When you capture an opponent's piece, it becomes yours. It is placed in your "hand" and can be returned to the board later.

+

On any turn, instead of moving a piece, you may "drop" a captured piece onto any empty tile. This makes Shogi very dynamic as the board can change rapidly.

+

There are rules for drops:

+
    +
  • Dropped pieces always start unpromoted.
  • +
  • You may not drop a piece where it has no future moves (like a Pawn on the last rank).
  • +
  • You cannot drop a Pawn into a column that already has another of your unpromoted Pawns.
  • +
  • You cannot drop a Pawn to deliver immediate checkmate.
  • +
+ +

The King and "Check"

+

If your King is under attack, it is in "check". You must make a move to protect the King immediately.

+

You can move the King to safety, capture the attacking piece, or block the attack with another piece.

+ +

Victory

+

The goal is to capture the opponent's King. If the King is in check and cannot escape, it is "checkmate" and you win.

+
+
+ +@code { + private string activeSessionName = string.Empty; + private Task OnLoginChanged() + { + StateHasChanged(); + return Task.CompletedTask; + } + private void OnChangeSession(SessionMetadata s) + { + activeSessionName = s.SessionId.ToString(); + StateHasChanged(); + } +} diff --git a/Shogi/FrontEnd/Components/Pages/Home/HomePage.razor.css b/Shogi/FrontEnd/Components/Pages/Home/HomePage.razor.css new file mode 100644 index 0000000..a24a0a2 --- /dev/null +++ b/Shogi/FrontEnd/Components/Pages/Home/HomePage.razor.css @@ -0,0 +1,74 @@ +.shogi { + background-color: var(--primary-color); + color: white; + padding: 1rem; + margin: auto; + width: 100%; + max-width: 80ch; + padding-top: 0; +} + +.shogi-toc { + display: none; +} + +.shogi-content h2 { + text-align: center; + border-bottom: 1px solid white; + margin-bottom: 1.5rem; +} + +@media (min-width: 1025px) { + .shogi { + max-width: 140ch; + display: grid; + grid-template-columns: 250px 1fr; + gap: 4rem; + align-items: start; + } + + .shogi-toc { + display: block; + position: sticky; + top: 2rem; + max-height: calc(100vh - 4rem); + overflow-y: auto; + } + + .shogi-toc h3 { + margin-top: 0; + font-size: 1.25rem; + border-bottom: 1px solid rgba(255,255,255,0.3); + padding-bottom: 0.5rem; + margin-bottom: 1rem; + } + + .shogi-toc ul { + list-style: none; + padding-left: 0; + } + + .shogi-toc > ul > li { + margin-bottom: 0.75rem; + font-weight: bold; + } + + .shogi-toc ul ul { + padding-left: 1rem; + margin-top: 0.25rem; + font-weight: normal; + font-size: 0.9em; + } + + .shogi-toc a { + color: rgba(255,255,255,0.8); + text-decoration: none; + display: block; + padding: 0.1rem 0; + } + + .shogi-toc a:hover { + color: white; + text-decoration: underline; + } +} diff --git a/Shogi.UI/Pages/Home/VisualAids/BishopMoves.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/BishopMoves.razor similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/BishopMoves.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/BishopMoves.razor diff --git a/Shogi.UI/Pages/Home/VisualAids/BoardSetupVisualAid.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/BoardSetupVisualAid.razor similarity index 98% rename from Shogi.UI/Pages/Home/VisualAids/BoardSetupVisualAid.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/BoardSetupVisualAid.razor index 8ad5251..9dc4db1 100644 --- a/Shogi.UI/Pages/Home/VisualAids/BoardSetupVisualAid.razor +++ b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/BoardSetupVisualAid.razor @@ -1,6 +1,4 @@ -@using Shogi.Contracts.Types - -
+
diff --git a/Shogi.UI/Pages/Home/VisualAids/BoardSetupVisualAid.razor.css b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/BoardSetupVisualAid.razor.css similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/BoardSetupVisualAid.razor.css rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/BoardSetupVisualAid.razor.css diff --git a/Shogi.UI/Pages/Home/VisualAids/DragonMoves.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/DragonMoves.razor similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/DragonMoves.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/DragonMoves.razor diff --git a/Shogi.UI/Pages/Home/VisualAids/GoldGeneralMoves.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/GoldGeneralMoves.razor similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/GoldGeneralMoves.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/GoldGeneralMoves.razor diff --git a/Shogi.UI/Pages/Home/VisualAids/HorseMoves.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/HorseMoves.razor similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/HorseMoves.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/HorseMoves.razor diff --git a/Shogi.UI/Pages/Home/VisualAids/KingMoves.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/KingMoves.razor similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/KingMoves.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/KingMoves.razor diff --git a/Shogi.UI/Pages/Home/VisualAids/KnightMoves.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/KnightMoves.razor similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/KnightMoves.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/KnightMoves.razor diff --git a/Shogi.UI/Pages/Home/VisualAids/LanceMoves.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/LanceMoves.razor similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/LanceMoves.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/LanceMoves.razor diff --git a/Shogi.UI/Pages/Home/VisualAids/PawnMoves.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/PawnMoves.razor similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/PawnMoves.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/PawnMoves.razor diff --git a/Shogi.UI/Pages/Home/VisualAids/PieceMovesVisualAid.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/PieceMovesVisualAid.razor similarity index 96% rename from Shogi.UI/Pages/Home/VisualAids/PieceMovesVisualAid.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/PieceMovesVisualAid.razor index 6256045..0b3db6b 100644 --- a/Shogi.UI/Pages/Home/VisualAids/PieceMovesVisualAid.razor +++ b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/PieceMovesVisualAid.razor @@ -22,17 +22,21 @@
+ +
+
Knight
-
-
Silver General
+
+ +
Gold General
diff --git a/Shogi.UI/Pages/Home/VisualAids/PieceMovesVisualAid.razor.css b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/PieceMovesVisualAid.razor.css similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/PieceMovesVisualAid.razor.css rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/PieceMovesVisualAid.razor.css diff --git a/Shogi.UI/Pages/Home/VisualAids/PromotedKnightMoves.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/PromotedKnightMoves.razor similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/PromotedKnightMoves.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/PromotedKnightMoves.razor diff --git a/Shogi.UI/Pages/Home/VisualAids/PromotedLanceMoves.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/PromotedLanceMoves.razor similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/PromotedLanceMoves.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/PromotedLanceMoves.razor diff --git a/Shogi.UI/Pages/Home/VisualAids/PromotedPawnMoves.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/PromotedPawnMoves.razor similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/PromotedPawnMoves.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/PromotedPawnMoves.razor diff --git a/Shogi.UI/Pages/Home/VisualAids/PromotedPieceVisualAid.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/PromotedPieceVisualAid.razor similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/PromotedPieceVisualAid.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/PromotedPieceVisualAid.razor index 7fc0731..b5ff475 100644 --- a/Shogi.UI/Pages/Home/VisualAids/PromotedPieceVisualAid.razor +++ b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/PromotedPieceVisualAid.razor @@ -21,14 +21,14 @@
Promoted Lance
+
+
Promoted Knight
-
-
Promoted Silver General
diff --git a/Shogi.UI/Pages/Home/VisualAids/PromotedPieceVisualAid.razor.css b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/PromotedPieceVisualAid.razor.css similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/PromotedPieceVisualAid.razor.css rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/PromotedPieceVisualAid.razor.css diff --git a/Shogi.UI/Pages/Home/VisualAids/PromotedSilverMoves.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/PromotedSilverMoves.razor similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/PromotedSilverMoves.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/PromotedSilverMoves.razor diff --git a/Shogi.UI/Pages/Home/VisualAids/RookMoves.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/RookMoves.razor similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/RookMoves.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/RookMoves.razor diff --git a/Shogi.UI/Pages/Home/VisualAids/SilverMoves.razor b/Shogi/FrontEnd/Components/Pages/Home/VisualAids/SilverMoves.razor similarity index 100% rename from Shogi.UI/Pages/Home/VisualAids/SilverMoves.razor rename to Shogi/FrontEnd/Components/Pages/Home/VisualAids/SilverMoves.razor diff --git a/Shogi/FrontEnd/Components/Pages/Home/_Imports.razor b/Shogi/FrontEnd/Components/Pages/Home/_Imports.razor new file mode 100644 index 0000000..b57aff1 --- /dev/null +++ b/Shogi/FrontEnd/Components/Pages/Home/_Imports.razor @@ -0,0 +1 @@ +@using Shogi.FrontEnd.Components.Pages.Home.VisualAids \ No newline at end of file diff --git a/Shogi.UI/Pages/Identity/ForgotPassword.razor b/Shogi/FrontEnd/Components/Pages/Identity/ForgotPassword.razor similarity index 100% rename from Shogi.UI/Pages/Identity/ForgotPassword.razor rename to Shogi/FrontEnd/Components/Pages/Identity/ForgotPassword.razor diff --git a/Shogi.UI/Pages/Identity/ForgotPassword.razor.css b/Shogi/FrontEnd/Components/Pages/Identity/ForgotPassword.razor.css similarity index 100% rename from Shogi.UI/Pages/Identity/ForgotPassword.razor.css rename to Shogi/FrontEnd/Components/Pages/Identity/ForgotPassword.razor.css diff --git a/Shogi/FrontEnd/Components/Pages/Identity/LoginPage.razor b/Shogi/FrontEnd/Components/Pages/Identity/LoginPage.razor new file mode 100644 index 0000000..dcf0e9c --- /dev/null +++ b/Shogi/FrontEnd/Components/Pages/Identity/LoginPage.razor @@ -0,0 +1,40 @@ +@page "/login" +@inject NavigationManager navigator + +
+ +
+

Login

+ + +
You're logged in as @context.User.Identity?.Name.
+
+ + @if (!string.IsNullOrEmpty(Error)) + { +
+
    +
  • @Error
  • +
+
+ } + + + + + + + + Reset password + + +
+
+
+ +
+ +@code { + [SupplyParameterFromQuery] + public string? Error { get; set; } +} \ No newline at end of file diff --git a/Shogi/FrontEnd/Components/Pages/Identity/LoginPage.razor.css b/Shogi/FrontEnd/Components/Pages/Identity/LoginPage.razor.css new file mode 100644 index 0000000..f86dbcd --- /dev/null +++ b/Shogi/FrontEnd/Components/Pages/Identity/LoginPage.razor.css @@ -0,0 +1,52 @@ +main { + padding: 1rem; + display: grid; + place-content: flex-start center; +} + +.LoginForm { + grid-area: form; + display: inline-grid; + grid-template-areas: + "h1 h1" + "errors errors" + "emailLabel emailControl" + "passLabel passControl" + ". resetLink" + "button button"; + gap: 0.5rem 3rem; + color: var(--backgroundColor); + background-color: var(--middlegroundColor); + padding: 2rem; +} +.LoginForm a { + color: var(--middlegroundHrefColor); +} + + .LoginForm .Errors { + color: darkred; + background-color: var(--foregroundColor); + display: flex; + flex-direction: column; + } + + .LoginForm .Errors ul { + margin: 0.5rem 0; + } + +.ErrorProgress { + height: 5px; + background-color: darkred; + width: 100%; + animation: deplete 10s linear forwards; +} + +@keyframes deplete { + from { + width: 100%; + } + + to { + width: 0%; + } +} diff --git a/Shogi.UI/Pages/Identity/LogoutPage.razor b/Shogi/FrontEnd/Components/Pages/Identity/LogoutPage.razor similarity index 100% rename from Shogi.UI/Pages/Identity/LogoutPage.razor rename to Shogi/FrontEnd/Components/Pages/Identity/LogoutPage.razor diff --git a/Shogi.UI/Pages/Identity/RegisterPage.razor b/Shogi/FrontEnd/Components/Pages/Identity/RegisterPage.razor similarity index 100% rename from Shogi.UI/Pages/Identity/RegisterPage.razor rename to Shogi/FrontEnd/Components/Pages/Identity/RegisterPage.razor diff --git a/Shogi.UI/Pages/Identity/RegisterPage.razor.css b/Shogi/FrontEnd/Components/Pages/Identity/RegisterPage.razor.css similarity index 100% rename from Shogi.UI/Pages/Identity/RegisterPage.razor.css rename to Shogi/FrontEnd/Components/Pages/Identity/RegisterPage.razor.css diff --git a/Shogi/FrontEnd/Components/Pages/Play/GameBoard/EmptyGameBoard.razor b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/EmptyGameBoard.razor new file mode 100644 index 0000000..b24e7d0 --- /dev/null +++ b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/EmptyGameBoard.razor @@ -0,0 +1,4 @@ + + +@code { +} diff --git a/Shogi.UI/Pages/Play/GameBoard/GameBoard.razor b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/GameBoard.razor similarity index 90% rename from Shogi.UI/Pages/Play/GameBoard/GameBoard.razor rename to Shogi/FrontEnd/Components/Pages/Play/GameBoard/GameBoard.razor index 1724e1b..a9d722e 100644 --- a/Shogi.UI/Pages/Play/GameBoard/GameBoard.razor +++ b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/GameBoard.razor @@ -1,10 +1,8 @@ -@using Shogi.Contracts.Api -@using Shogi.Contracts.Types -@using System.Text.RegularExpressions +@using System.Text.RegularExpressions @using System.Security.Claims @implements IDisposable -@inject ShogiApi ShogiApi +@inject ShogiService Service @inject GameHubNode hubNode @inject NavigationManager navigator @@ -52,7 +50,7 @@ else { if (!string.IsNullOrWhiteSpace(SessionId)) { - this.session = await ShogiApi.GetSession(SessionId); + this.session = await Service.GetSession(SessionId); if (this.session != null) { var state = await authenticationState; diff --git a/Shogi.UI/Pages/Play/GameBoard/GameBoardPresentation.razor b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/GameBoardPresentation.razor similarity index 99% rename from Shogi.UI/Pages/Play/GameBoard/GameBoardPresentation.razor rename to Shogi/FrontEnd/Components/Pages/Play/GameBoard/GameBoardPresentation.razor index 21679ab..8bd7339 100644 --- a/Shogi.UI/Pages/Play/GameBoard/GameBoardPresentation.razor +++ b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/GameBoardPresentation.razor @@ -1,5 +1,4 @@ -@using Shogi.Contracts.Types; -@using System.Text.Json; +@using System.Text.Json;
diff --git a/Shogi.UI/Pages/Play/GameBoard/GameboardPresentation.razor.css b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/GameboardPresentation.razor.css similarity index 100% rename from Shogi.UI/Pages/Play/GameBoard/GameboardPresentation.razor.css rename to Shogi/FrontEnd/Components/Pages/Play/GameBoard/GameboardPresentation.razor.css diff --git a/Shogi.UI/Pages/Play/GameBoard/OpponentName.razor b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/OpponentName.razor similarity index 90% rename from Shogi.UI/Pages/Play/GameBoard/OpponentName.razor rename to Shogi/FrontEnd/Components/Pages/Play/GameBoard/OpponentName.razor index da3fc0c..ce82dd6 100644 --- a/Shogi.UI/Pages/Play/GameBoard/OpponentName.razor +++ b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/OpponentName.razor @@ -1,5 +1,4 @@ -@using Shogi.Contracts.Types -
+
@if (IsTurn) { diff --git a/Shogi.UI/Pages/Play/GameBoard/OpponentName.razor.css b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/OpponentName.razor.css similarity index 100% rename from Shogi.UI/Pages/Play/GameBoard/OpponentName.razor.css rename to Shogi/FrontEnd/Components/Pages/Play/GameBoard/OpponentName.razor.css diff --git a/Shogi.UI/Pages/Play/GameBoard/PlayerName.razor b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/PlayerName.razor similarity index 89% rename from Shogi.UI/Pages/Play/GameBoard/PlayerName.razor rename to Shogi/FrontEnd/Components/Pages/Play/GameBoard/PlayerName.razor index 05d961b..615a8dd 100644 --- a/Shogi.UI/Pages/Play/GameBoard/PlayerName.razor +++ b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/PlayerName.razor @@ -1,5 +1,4 @@ -@using Shogi.Contracts.Types -
+
@if (string.IsNullOrEmpty(Name)) { diff --git a/Shogi.UI/Pages/Play/GameBoard/PlayerName.razor.css b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/PlayerName.razor.css similarity index 100% rename from Shogi.UI/Pages/Play/GameBoard/PlayerName.razor.css rename to Shogi/FrontEnd/Components/Pages/Play/GameBoard/PlayerName.razor.css diff --git a/Shogi.UI/Pages/Play/GameBoard/SeatedGameBoard.razor b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/SeatedGameBoard.razor similarity index 86% rename from Shogi.UI/Pages/Play/GameBoard/SeatedGameBoard.razor rename to Shogi/FrontEnd/Components/Pages/Play/GameBoard/SeatedGameBoard.razor index fc66d1c..198706b 100644 --- a/Shogi.UI/Pages/Play/GameBoard/SeatedGameBoard.razor +++ b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/SeatedGameBoard.razor @@ -1,8 +1,7 @@ -@using Shogi.Contracts.Api.Commands -@using Shogi.Contracts.Types; @using System.Text.RegularExpressions; @using System.Net; -@inject ShogiApi ShogiApi; +@using System.Security.Claims +@inject ShogiService Service @code { + [CascadingParameter] + private Task AuthState { get; set; } = default!; + [Parameter, EditorRequired] public WhichPlayer Perspective { get; set; } [Parameter, EditorRequired] @@ -43,16 +45,18 @@ private bool showPromotePrompt; private string? moveTo; private bool showError = false; + private string? userId; - protected override void OnParametersSet() + protected override async Task OnParametersSetAsync() { - base.OnParametersSet(); selectedBoardPosition = null; selectedPieceFromHand = null; if (Session == null) { throw new ArgumentException($"{nameof(Session)} cannot be null.", nameof(Session)); } + var state = await AuthState; + userId = state.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; } bool IsWithinPromoteArea(string position) @@ -96,8 +100,7 @@ if (pieceAtPosition is null) { // Placing a piece from the hand to an empty space. - var success = await ShogiApi.Move( - Session.SessionId, + var success = await Service.Move(userId!, Session.SessionId, new MovePieceCommand(selectedPieceFromHand.Value, position)); if (!success) { @@ -123,7 +126,7 @@ } else { - var success = await ShogiApi.Move(Session.SessionId, new MovePieceCommand(selectedBoardPosition, position, false)); + var success = await Service.Move(userId!, Session.SessionId, new MovePieceCommand(selectedBoardPosition, position, false)); if (!success) { selectedBoardPosition = null; @@ -155,7 +158,7 @@ { if (selectedBoardPosition != null && moveTo != null) { - showError = await ShogiApi.Move(Session.SessionId, new MovePieceCommand(selectedBoardPosition, moveTo, shouldPromote)); + showError = await Service.Move(userId!, Session.SessionId, new MovePieceCommand(selectedBoardPosition, moveTo, shouldPromote)); showPromotePrompt = false; return; } @@ -168,3 +171,4 @@ showError = false; } } + diff --git a/Shogi.UI/Pages/Play/GameBoard/SeatedGameBoard.razor.css b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/SeatedGameBoard.razor.css similarity index 100% rename from Shogi.UI/Pages/Play/GameBoard/SeatedGameBoard.razor.css rename to Shogi/FrontEnd/Components/Pages/Play/GameBoard/SeatedGameBoard.razor.css diff --git a/Shogi.UI/Pages/Play/GameBoard/SpectatorGameBoard.razor b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/SpectatorGameBoard.razor similarity index 59% rename from Shogi.UI/Pages/Play/GameBoard/SpectatorGameBoard.razor rename to Shogi/FrontEnd/Components/Pages/Play/GameBoard/SpectatorGameBoard.razor index fcc8dd2..7a6e941 100644 --- a/Shogi.UI/Pages/Play/GameBoard/SpectatorGameBoard.razor +++ b/Shogi/FrontEnd/Components/Pages/Play/GameBoard/SpectatorGameBoard.razor @@ -1,6 +1,6 @@ -@using Contracts.Types -@using System.Net -@inject ShogiApi ShogiApi +@using System.Net +@using System.Security.Claims +@inject ShogiService Service @code { + [CascadingParameter] + private Task AuthState { get; set; } = default!; + [Parameter] [EditorRequired] public Session Session { get; set; } = default!; @@ -25,7 +28,10 @@ async Task OnClickJoinGame() { - var response = await ShogiApi.PatchJoinGame(Session.SessionId.ToString()); - response.EnsureSuccessStatusCode(); + var state = await AuthState; + var userId = state.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userId is null) return; + + await Service.JoinSession(Session.SessionId.ToString(), userId); } } diff --git a/Shogi.UI/Pages/Play/GameBrowser.razor b/Shogi/FrontEnd/Components/Pages/Play/GameBrowser.razor similarity index 51% rename from Shogi.UI/Pages/Play/GameBrowser.razor rename to Shogi/FrontEnd/Components/Pages/Play/GameBrowser.razor index 81d47fd..63028cf 100644 --- a/Shogi.UI/Pages/Play/GameBrowser.razor +++ b/Shogi/FrontEnd/Components/Pages/Play/GameBrowser.razor @@ -1,9 +1,8 @@ -@using Shogi.Contracts.Types; -@using System.ComponentModel.DataAnnotations; +@using System.ComponentModel.DataAnnotations; @using System.Net; @using System.Text.Json; -@inject ShogiApi ShogiApi +@inject ShogiService Service
@@ -13,10 +12,13 @@
- @foreach (var session in allSessions) + @foreach (var session in allSessions) { - + }
@@ -30,6 +32,9 @@ @code { private SessionMetadata[] allSessions = Array.Empty(); + [Parameter] public SessionMetadata? SelectedSession { get; set; } + [Parameter] public EventCallback OnSessionSelected { get; set; } + protected override Task OnInitializedAsync() { return FetchSessions(); @@ -37,7 +42,7 @@ async Task FetchSessions() { - var sessions = await ShogiApi.GetAllSessionsMetadata(); + var sessions = await Service.GetAllSessionsMetadata(null); Console.WriteLine("Session count {0}", sessions.Length); if (sessions != null) { @@ -45,4 +50,9 @@ StateHasChanged(); } } + + private async Task HandleSessionSelected(SessionMetadata session) + { + await OnSessionSelected.InvokeAsync(session); + } } diff --git a/Shogi.UI/Pages/Play/GameBrowser.razor.css b/Shogi/FrontEnd/Components/Pages/Play/GameBrowser.razor.css similarity index 100% rename from Shogi.UI/Pages/Play/GameBrowser.razor.css rename to Shogi/FrontEnd/Components/Pages/Play/GameBrowser.razor.css diff --git a/Shogi/FrontEnd/Components/Pages/Play/GameBrowserEntry.razor b/Shogi/FrontEnd/Components/Pages/Play/GameBrowserEntry.razor new file mode 100644 index 0000000..b5b65d6 --- /dev/null +++ b/Shogi/FrontEnd/Components/Pages/Play/GameBrowserEntry.razor @@ -0,0 +1,88 @@ +@inject ShogiService Service +@using System.Security.Claims + + +@if (showDeletePrompt) +{ + +
+@if (showDeleteError) +{ +

An error occurred.

+
+ +} +else +{ +

Do you wish to delete this session?

+
+ + +} +
+ +} + +
+@Session.Player1 +
+ @if (string.IsNullOrEmpty(Session.Player2)) + { + 1 / 2 + } + else + { + Full + } + + @if (context.User.Identity?.Name == Session.Player1) + { + + + + } + + + +@code { + [CascadingParameter] + private Task AuthState { get; set; } = default!; + + [Parameter][EditorRequired] public SessionMetadata Session { get; set; } = default!; + [Parameter][EditorRequired] public EventCallback OnSessionDeleted { get; set; } + [Parameter] public EventCallback OnSessionSelected { get; set; } + [Parameter] public bool IsSelected { get; set; } + private bool showDeletePrompt = false; + private bool showDeleteError = false; + + private async Task HandleSessionSelectedClick() + { + await OnSessionSelected.InvokeAsync(Session); + } + + + void HideModal() + { + showDeletePrompt = showDeleteError = false; + } + + async Task DeleteSession() + { + var state = await AuthState; + var userId = state.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userId is null) return; + + var success = await Service.DeleteSession(Session.SessionId, userId); + if (success) + { + showDeletePrompt = false; + showDeleteError = false; + await OnSessionDeleted.InvokeAsync(); + } + else + { + showDeletePrompt = true; + showDeleteError = true; + } + } +} diff --git a/Shogi.UI/Pages/Play/GameBrowserEntry.razor.css b/Shogi/FrontEnd/Components/Pages/Play/GameBrowserEntry.razor.css similarity index 52% rename from Shogi.UI/Pages/Play/GameBrowserEntry.razor.css rename to Shogi/FrontEnd/Components/Pages/Play/GameBrowserEntry.razor.css index 9c18c06..b00c83f 100644 --- a/Shogi.UI/Pages/Play/GameBrowserEntry.razor.css +++ b/Shogi/FrontEnd/Components/Pages/Play/GameBrowserEntry.razor.css @@ -5,8 +5,18 @@ padding-left: 5px; /* Matches box shadow on hover */ gap: 1rem; place-items: center start; + cursor: pointer; } + gameBrowserEntry:hover { + background-color: rgba(255, 255, 255, 0.1); + } + + gameBrowserEntry[data-selected="true"] { + background-color: rgba(255, 255, 255, 0.2); + box-shadow: inset 3px 0 0 0 var(--primary-color, #fff); + } + modal { position: absolute; top: 0; diff --git a/Shogi.UI/Pages/Play/GamePiece.razor b/Shogi/FrontEnd/Components/Pages/Play/GamePiece.razor similarity index 94% rename from Shogi.UI/Pages/Play/GamePiece.razor rename to Shogi/FrontEnd/Components/Pages/Play/GamePiece.razor index b4ce0ae..6c09228 100644 --- a/Shogi.UI/Pages/Play/GamePiece.razor +++ b/Shogi/FrontEnd/Components/Pages/Play/GamePiece.razor @@ -1,6 +1,4 @@ -@using Shogi.Contracts.Types - -
+
@switch (Piece) { case WhichPiece.Bishop: diff --git a/Shogi.UI/Pages/Play/GamePiece.razor.css b/Shogi/FrontEnd/Components/Pages/Play/GamePiece.razor.css similarity index 100% rename from Shogi.UI/Pages/Play/GamePiece.razor.css rename to Shogi/FrontEnd/Components/Pages/Play/GamePiece.razor.css diff --git a/Shogi.UI/Pages/Play/Pieces/Bishop.razor b/Shogi/FrontEnd/Components/Pages/Play/Pieces/Bishop.razor similarity index 100% rename from Shogi.UI/Pages/Play/Pieces/Bishop.razor rename to Shogi/FrontEnd/Components/Pages/Play/Pieces/Bishop.razor diff --git a/Shogi.UI/Pages/Play/Pieces/ChallengingKing.razor b/Shogi/FrontEnd/Components/Pages/Play/Pieces/ChallengingKing.razor similarity index 100% rename from Shogi.UI/Pages/Play/Pieces/ChallengingKing.razor rename to Shogi/FrontEnd/Components/Pages/Play/Pieces/ChallengingKing.razor diff --git a/Shogi.UI/Pages/Play/Pieces/GoldGeneral.razor b/Shogi/FrontEnd/Components/Pages/Play/Pieces/GoldGeneral.razor similarity index 100% rename from Shogi.UI/Pages/Play/Pieces/GoldGeneral.razor rename to Shogi/FrontEnd/Components/Pages/Play/Pieces/GoldGeneral.razor diff --git a/Shogi.UI/Pages/Play/Pieces/Knight.razor b/Shogi/FrontEnd/Components/Pages/Play/Pieces/Knight.razor similarity index 100% rename from Shogi.UI/Pages/Play/Pieces/Knight.razor rename to Shogi/FrontEnd/Components/Pages/Play/Pieces/Knight.razor diff --git a/Shogi.UI/Pages/Play/Pieces/Lance.razor b/Shogi/FrontEnd/Components/Pages/Play/Pieces/Lance.razor similarity index 100% rename from Shogi.UI/Pages/Play/Pieces/Lance.razor rename to Shogi/FrontEnd/Components/Pages/Play/Pieces/Lance.razor diff --git a/Shogi.UI/Pages/Play/Pieces/Pawn.razor b/Shogi/FrontEnd/Components/Pages/Play/Pieces/Pawn.razor similarity index 100% rename from Shogi.UI/Pages/Play/Pieces/Pawn.razor rename to Shogi/FrontEnd/Components/Pages/Play/Pieces/Pawn.razor diff --git a/Shogi.UI/Pages/Play/Pieces/ReigningKing.razor b/Shogi/FrontEnd/Components/Pages/Play/Pieces/ReigningKing.razor similarity index 100% rename from Shogi.UI/Pages/Play/Pieces/ReigningKing.razor rename to Shogi/FrontEnd/Components/Pages/Play/Pieces/ReigningKing.razor diff --git a/Shogi.UI/Pages/Play/Pieces/Rook.razor b/Shogi/FrontEnd/Components/Pages/Play/Pieces/Rook.razor similarity index 100% rename from Shogi.UI/Pages/Play/Pieces/Rook.razor rename to Shogi/FrontEnd/Components/Pages/Play/Pieces/Rook.razor diff --git a/Shogi.UI/Pages/Play/Pieces/SilverGeneral.razor b/Shogi/FrontEnd/Components/Pages/Play/Pieces/SilverGeneral.razor similarity index 100% rename from Shogi.UI/Pages/Play/Pieces/SilverGeneral.razor rename to Shogi/FrontEnd/Components/Pages/Play/Pieces/SilverGeneral.razor diff --git a/Shogi.UI/Pages/Play/PlayPage.razor b/Shogi/FrontEnd/Components/Pages/Play/PlayPage.razor similarity index 100% rename from Shogi.UI/Pages/Play/PlayPage.razor rename to Shogi/FrontEnd/Components/Pages/Play/PlayPage.razor diff --git a/Shogi/FrontEnd/Components/Pages/SearchPage.razor b/Shogi/FrontEnd/Components/Pages/SearchPage.razor new file mode 100644 index 0000000..d957021 --- /dev/null +++ b/Shogi/FrontEnd/Components/Pages/SearchPage.razor @@ -0,0 +1,57 @@ +@page "/search" + +@inject ShogiService Service +@inject NavigationManager Navigation + +
+

Find Sessions

+ +
+ + + +
+
+ +@code { + private SessionMetadata? selectedSession; + private Session? previewSession; + + private async Task HandleSessionSelected(SessionMetadata session) + { + selectedSession = session; + previewSession = null; + + previewSession = await Service.GetSession(session.SessionId.ToString()); + } + + private void JoinGame() + { + if (selectedSession is not null) + { + Navigation.NavigateTo($"/play/{selectedSession.SessionId}"); + } + } +} diff --git a/Shogi/FrontEnd/Components/Pages/SearchPage.razor.css b/Shogi/FrontEnd/Components/Pages/SearchPage.razor.css new file mode 100644 index 0000000..58cbf6f --- /dev/null +++ b/Shogi/FrontEnd/Components/Pages/SearchPage.razor.css @@ -0,0 +1,39 @@ +.SearchPage { + padding: 0 0.5rem; + display: grid; + grid-template-columns: 100%; + grid-template-rows: auto 1fr; +} + +.search-content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + align-items: start; +} + +.preview-panel { + padding: 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + min-height: 300px; +} + +.preview-panel .no-selection { + color: #888; + font-style: italic; +} + +.preview-actions { + display: flex; + gap: 1rem; + justify-content: center; + margin-top: 1rem; +} + +.preview-actions button { + padding: 0.5rem 1.5rem; + font-size: 1rem; +} diff --git a/Shogi.UI/Shared/RotatingCogsSvg.razor b/Shogi/FrontEnd/Components/RotatingCogsSvg.razor similarity index 100% rename from Shogi.UI/Shared/RotatingCogsSvg.razor rename to Shogi/FrontEnd/Components/RotatingCogsSvg.razor diff --git a/Shogi.UI/Shared/RotatingCogsSvg.razor.css b/Shogi/FrontEnd/Components/RotatingCogsSvg.razor.css similarity index 100% rename from Shogi.UI/Shared/RotatingCogsSvg.razor.css rename to Shogi/FrontEnd/Components/RotatingCogsSvg.razor.css diff --git a/Shogi.UI/App.razor b/Shogi/FrontEnd/Components/Routes.razor similarity index 73% rename from Shogi.UI/App.razor rename to Shogi/FrontEnd/Components/Routes.razor index e7359b7..f3d6a9f 100644 --- a/Shogi.UI/App.razor +++ b/Shogi/FrontEnd/Components/Routes.razor @@ -1,8 +1,8 @@ - - - + + + @* *@ Not found @@ -11,5 +11,4 @@ - diff --git a/Shogi.UI/Shared/Stretch.razor b/Shogi/FrontEnd/Components/Stretch.razor similarity index 100% rename from Shogi.UI/Shared/Stretch.razor rename to Shogi/FrontEnd/Components/Stretch.razor diff --git a/Shogi.UI/Shared/Stretch.razor.css b/Shogi/FrontEnd/Components/Stretch.razor.css similarity index 100% rename from Shogi.UI/Shared/Stretch.razor.css rename to Shogi/FrontEnd/Components/Stretch.razor.css diff --git a/Shogi.UI/Shared/TemporaryModal.razor b/Shogi/FrontEnd/Components/TemporaryModal.razor similarity index 100% rename from Shogi.UI/Shared/TemporaryModal.razor rename to Shogi/FrontEnd/Components/TemporaryModal.razor diff --git a/Shogi.UI/Shared/TemporaryModal.razor.css b/Shogi/FrontEnd/Components/TemporaryModal.razor.css similarity index 100% rename from Shogi.UI/Shared/TemporaryModal.razor.css rename to Shogi/FrontEnd/Components/TemporaryModal.razor.css diff --git a/Shogi.UI/_Imports.razor b/Shogi/FrontEnd/Components/_Imports.razor similarity index 50% rename from Shogi.UI/_Imports.razor rename to Shogi/FrontEnd/Components/_Imports.razor index 2833a05..efec8a7 100644 --- a/Shogi.UI/_Imports.razor +++ b/Shogi/FrontEnd/Components/_Imports.razor @@ -1,4 +1,4 @@ -@using System.Net.Http +@using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @@ -6,12 +6,12 @@ @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web.Virtualization -@using Microsoft.AspNetCore.Components.WebAssembly.Http @using Microsoft.JSInterop -@using Shogi.UI.Identity -@using Shogi.UI.Layout -@using Shogi.UI.Pages.Play -@using Shogi.UI.Pages.Play.GameBoard -@using Shogi.UI.Pages.Play.Pieces -@using Shogi.UI.Shared -@using Shogi.UI.Shared.Icons \ No newline at end of file +@using Shogi.FrontEnd.Client +@using Shogi.FrontEnd.Components +@using Shogi.FrontEnd.Components.Layout +@using Shogi.FrontEnd.Components.Pages.Play +@using Shogi.FrontEnd.Components.Pages.Play.GameBoard +@using Shogi.FrontEnd.Components.Pages.Play.Pieces +@using Shogi.FrontEnd.Components.Icons +@using Shogi.BackEnd.Types diff --git a/Shogi/Program.cs b/Shogi/Program.cs new file mode 100644 index 0000000..a0d12e8 --- /dev/null +++ b/Shogi/Program.cs @@ -0,0 +1,184 @@ +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.ResponseCompression; +using Microsoft.EntityFrameworkCore; +using Shogi; +using Shogi.BackEnd.Application; +using Shogi.BackEnd.Controllers; +using Shogi.BackEnd.Identity; +using Shogi.BackEnd.Repositories; +using Shogi.FrontEnd.Client; +using Shogi.FrontEnd.Components; + +var builder = WebApplication.CreateBuilder(args); + +// Add Blazor components +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +// Add API controllers +builder.Services + .AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.WriteIndented = true; + }); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// Application services +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddHttpClient(); +builder.Services.Configure(builder.Configuration.GetSection("ApiKeys")); + +// Client services for Blazor components +builder.Services.AddTransient(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); + +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(builder.Environment); + +if (app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); +} +else +{ + app.UseExceptionHandler("/Error"); + app.UseHsts(); + app.UseResponseCompression(); +} + +app.UseStaticFiles(); +app.UseAntiforgery(); + +app.UseSwagger(); +app.UseSwaggerUI(options => options.DocumentTitle = "Shogi API"); +app.UseAuthorization(); + +app.MapControllers(); +app.MapHub("/gamehub"); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +#if DEBUG +MapTestAccountSetupRoute(app); +#endif + +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(options => + { + var cs = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("Database not configured."); + options.UseSqlServer(cs); + }) + .AddIdentityApiEndpoints(options => + { + options.SignIn.RequireConfirmedEmail = true; + options.User.RequireUniqueEmail = true; + }) + .AddEntityFrameworkStores(); + + builder.Services.ConfigureApplicationCookie(options => + { + options.SlidingExpiration = true; + options.ExpireTimeSpan = TimeSpan.FromDays(3); + }); +} + +static void MapTestAccountSetupRoute(WebApplication app) +{ + app.MapPost("/debug/create-test-users", async ( + UserManager userManager, + QueryRepository queryRepository, + SessionRepository sessionRepository) => + { + var testAccounts = new[] + { + new { Email = "test1@example.com", Password = "Test123!" }, + new { Email = "test2@example.com", Password = "Test123!" } + }; + + var createdUsers = new List(); + var errors = new List(); + + foreach (var account in testAccounts) + { + // Delete existing user and their sessions if they exist + var existingUser = await userManager.FindByEmailAsync(account.Email); + if (existingUser != null) + { + // Delete all game sessions associated with this user + var sessions = await queryRepository.ReadSessionsMetadata(existingUser.Id); + foreach (var session in sessions) + { + await sessionRepository.DeleteSession(session.Id); + } + + // Delete the existing user + await userManager.DeleteAsync(existingUser); + } + + // Create fresh test user + var testUser = new ShogiUser + { + UserName = account.Email, + Email = account.Email, + EmailConfirmed = true + }; + + var result = await userManager.CreateAsync(testUser, account.Password); + + if (result.Succeeded) + { + createdUsers.Add(new { email = account.Email, password = account.Password }); + } + else + { + errors.Add(new { email = account.Email, errors = result.Errors }); + } + } + + if (errors.Count > 0) + { + return Results.BadRequest(new { message = "Failed to create some test users", createdUsers, errors }); + } + + return Results.Ok(new { message = "Test users created successfully", users = createdUsers }); + }) + .WithName("CreateTestUsers"); +} + +// Make Program accessible for WebApplicationFactory in integration tests +public partial class Program { } diff --git a/Shogi.Api/Properties/PublishProfiles/FolderProfile.pubxml b/Shogi/Properties/PublishProfiles/FolderProfile.pubxml similarity index 100% rename from Shogi.Api/Properties/PublishProfiles/FolderProfile.pubxml rename to Shogi/Properties/PublishProfiles/FolderProfile.pubxml diff --git a/Shogi.Api/Properties/ServiceDependencies/local/secrets1.arm.json b/Shogi/Properties/ServiceDependencies/local/secrets1.arm.json similarity index 100% rename from Shogi.Api/Properties/ServiceDependencies/local/secrets1.arm.json rename to Shogi/Properties/ServiceDependencies/local/secrets1.arm.json diff --git a/Shogi/Properties/launchSettings.json b/Shogi/Properties/launchSettings.json new file mode 100644 index 0000000..b369ea4 --- /dev/null +++ b/Shogi/Properties/launchSettings.json @@ -0,0 +1,22 @@ +{ + "profiles": { + "Shogi UI": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" + }, + "Swagger UI": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json" +} \ No newline at end of file diff --git a/Shogi.Api/Properties/serviceDependencies.json b/Shogi/Properties/serviceDependencies.json similarity index 100% rename from Shogi.Api/Properties/serviceDependencies.json rename to Shogi/Properties/serviceDependencies.json diff --git a/Shogi.Api/Properties/serviceDependencies.local.json b/Shogi/Properties/serviceDependencies.local.json similarity index 100% rename from Shogi.Api/Properties/serviceDependencies.local.json rename to Shogi/Properties/serviceDependencies.local.json diff --git a/Shogi.Api/Readme.md b/Shogi/Readme.md similarity index 100% rename from Shogi.Api/Readme.md rename to Shogi/Readme.md diff --git a/Shogi/Shogi.csproj b/Shogi/Shogi.csproj new file mode 100644 index 0000000..1d24355 --- /dev/null +++ b/Shogi/Shogi.csproj @@ -0,0 +1,94 @@ + + + + net10.0 + true + 5 + enable + False + False + enable + 973a1f5f-ef25-4f1c-a24d-b0fc7d016ab8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/Shogi.Api/appsettings.json b/Shogi/appsettings.json similarity index 90% rename from Shogi.Api/appsettings.json rename to Shogi/appsettings.json index 1946ca8..6108118 100644 --- a/Shogi.Api/appsettings.json +++ b/Shogi/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "ShogiDatabase": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=Shogi;Integrated Security=True;Application Name=Shogi.Api" + "ShogiDatabase": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=Shogi;Integrated Security=True;Application Name=Shogi" }, "Logging": { "LogLevel": { diff --git a/Shogi.UI/wwwroot/css/app.css b/Shogi/wwwroot/css/app.css similarity index 100% rename from Shogi.UI/wwwroot/css/app.css rename to Shogi/wwwroot/css/app.css diff --git a/Shogi.UI/wwwroot/css/themes.css b/Shogi/wwwroot/css/themes.css similarity index 98% rename from Shogi.UI/wwwroot/css/themes.css rename to Shogi/wwwroot/css/themes.css index 216e2e7..ea72a42 100644 --- a/Shogi.UI/wwwroot/css/themes.css +++ b/Shogi/wwwroot/css/themes.css @@ -3,6 +3,7 @@ --middlegroundColor: #D1D1D1; --foregroundColor: #eaeaea; --hrefColor: #99c3ff; + --middlegroundHrefColor: #0065be; --uniformBottomMargin: 0.5rem; background-color: var(--backgroundColor); color: var(--foregroundColor); diff --git a/Shogi.UI/wwwroot/favicon.ico b/Shogi/wwwroot/favicon.ico similarity index 100% rename from Shogi.UI/wwwroot/favicon.ico rename to Shogi/wwwroot/favicon.ico diff --git a/Shogi.UI/wwwroot/svgs/camera-reels.svg b/Shogi/wwwroot/svgs/camera-reels.svg similarity index 100% rename from Shogi.UI/wwwroot/svgs/camera-reels.svg rename to Shogi/wwwroot/svgs/camera-reels.svg diff --git a/Tests/AcceptanceTests/ApiTests.cs b/Tests/AcceptanceTests/ApiTests.cs index b069bb4..e4b3927 100644 --- a/Tests/AcceptanceTests/ApiTests.cs +++ b/Tests/AcceptanceTests/ApiTests.cs @@ -1,16 +1,11 @@ -using FluentAssertions.Execution; using Shogi.AcceptanceTests.TestSetup; -using Shogi.Contracts.Api.Commands; -using Shogi.Contracts.Types; +using Shogi.BackEnd.Types; using System.Net; using System.Net.Http.Json; -using Xunit.Abstractions; namespace Shogi.AcceptanceTests; -//#pragma warning disable xUnit1033 // There is a bug which provides a false positive of xUnit1033. -//#pragma warning restore xUnit1033 -public class ApiTests(AatTestFixture fixture, ITestOutputHelper console) : IClassFixture +public class ApiTests(AatTestFixture fixture) : IClassFixture { private readonly HttpClient httpClient = fixture.HttpClient; private readonly HttpClient player2HttpClient = fixture.OtherHttpClient; @@ -28,16 +23,16 @@ public class ApiTests(AatTestFixture fixture, ITestOutputHelper console) : IClas var response = await this.ReadTestSession(); // Assert - response.Should().NotBeNull(); - response!.BoardState.Board.Should().NotBeEmpty(); + Assert.NotNull(response); + Assert.NotEmpty(response!.BoardState.Board); ValidateBoard(response.BoardState.Board); - response.BoardState.Player1Hand.Should().BeEmpty(); - response.BoardState.Player2Hand.Should().BeEmpty(); - response.BoardState.PlayerInCheck.Should().BeNull(); - response.BoardState.WhoseTurn.Should().Be(WhichPlayer.Player1); - response.Player1.Should().NotBeNull(); - response.Player2.Should().BeNullOrEmpty(); - response.SessionId.Should().NotBeEmpty(); + Assert.Empty(response.BoardState.Player1Hand); + Assert.Empty(response.BoardState.Player2Hand); + Assert.Null(response.BoardState.PlayerInCheck); + Assert.Equal(WhichPlayer.Player1, response.BoardState.WhoseTurn); + Assert.NotNull(response.Player1); + Assert.True(string.IsNullOrEmpty(response.Player2)); + Assert.NotEqual(Guid.Empty, response.SessionId); } finally { @@ -47,176 +42,175 @@ public class ApiTests(AatTestFixture fixture, ITestOutputHelper console) : IClas static void ValidateBoard(Dictionary board) { - using var scope = new AssertionScope(); - board["A1"]!.WhichPiece.Should().Be(WhichPiece.Lance); - board["A1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["A1"]!.IsPromoted.Should().Be(false); - board["B1"]!.WhichPiece.Should().Be(WhichPiece.Knight); - board["B1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["B1"]!.IsPromoted.Should().Be(false); - board["C1"]!.WhichPiece.Should().Be(WhichPiece.SilverGeneral); - board["C1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["C1"]!.IsPromoted.Should().Be(false); - board["D1"]!.WhichPiece.Should().Be(WhichPiece.GoldGeneral); - board["D1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["D1"]!.IsPromoted.Should().Be(false); - board["E1"]!.WhichPiece.Should().Be(WhichPiece.King); - board["E1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["E1"]!.IsPromoted.Should().Be(false); - board["F1"]!.WhichPiece.Should().Be(WhichPiece.GoldGeneral); - board["F1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["F1"]!.IsPromoted.Should().Be(false); - board["G1"]!.WhichPiece.Should().Be(WhichPiece.SilverGeneral); - board["G1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["G1"]!.IsPromoted.Should().Be(false); - board["H1"]!.WhichPiece.Should().Be(WhichPiece.Knight); - board["H1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["H1"]!.IsPromoted.Should().Be(false); - board["I1"]!.WhichPiece.Should().Be(WhichPiece.Lance); - board["I1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["I1"]!.IsPromoted.Should().Be(false); + Assert.Equal(WhichPiece.Lance, board["A1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["A1"]!.Owner); + Assert.False(board["A1"]!.IsPromoted); + Assert.Equal(WhichPiece.Knight, board["B1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["B1"]!.Owner); + Assert.False(board["B1"]!.IsPromoted); + Assert.Equal(WhichPiece.SilverGeneral, board["C1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["C1"]!.Owner); + Assert.False(board["C1"]!.IsPromoted); + Assert.Equal(WhichPiece.GoldGeneral, board["D1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["D1"]!.Owner); + Assert.False(board["D1"]!.IsPromoted); + Assert.Equal(WhichPiece.King, board["E1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["E1"]!.Owner); + Assert.False(board["E1"]!.IsPromoted); + Assert.Equal(WhichPiece.GoldGeneral, board["F1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["F1"]!.Owner); + Assert.False(board["F1"]!.IsPromoted); + Assert.Equal(WhichPiece.SilverGeneral, board["G1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["G1"]!.Owner); + Assert.False(board["G1"]!.IsPromoted); + Assert.Equal(WhichPiece.Knight, board["H1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["H1"]!.Owner); + Assert.False(board["H1"]!.IsPromoted); + Assert.Equal(WhichPiece.Lance, board["I1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["I1"]!.Owner); + Assert.False(board["I1"]!.IsPromoted); - board["A2"].Should().BeNull(); - board["B2"]!.WhichPiece.Should().Be(WhichPiece.Bishop); - board["B2"]!.Owner.Should().Be(WhichPlayer.Player1); - board["B2"]!.IsPromoted.Should().Be(false); - board["C2"].Should().BeNull(); - board["D2"].Should().BeNull(); - board["E2"].Should().BeNull(); - board["F2"].Should().BeNull(); - board["G2"].Should().BeNull(); - board["H2"]!.WhichPiece.Should().Be(WhichPiece.Rook); - board["H2"]!.Owner.Should().Be(WhichPlayer.Player1); - board["H2"]!.IsPromoted.Should().Be(false); - board["I2"].Should().BeNull(); + Assert.Null(board["A2"]); + Assert.Equal(WhichPiece.Bishop, board["B2"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["B2"]!.Owner); + Assert.False(board["B2"]!.IsPromoted); + Assert.Null(board["C2"]); + Assert.Null(board["D2"]); + Assert.Null(board["E2"]); + Assert.Null(board["F2"]); + Assert.Null(board["G2"]); + Assert.Equal(WhichPiece.Rook, board["H2"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["H2"]!.Owner); + Assert.False(board["H2"]!.IsPromoted); + Assert.Null(board["I2"]); - board["A3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["A3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["A3"]!.IsPromoted.Should().Be(false); - board["B3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["B3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["B3"]!.IsPromoted.Should().Be(false); - board["C3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["C3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["C3"]!.IsPromoted.Should().Be(false); - board["D3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["D3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["D3"]!.IsPromoted.Should().Be(false); - board["E3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["E3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["E3"]!.IsPromoted.Should().Be(false); - board["F3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["F3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["F3"]!.IsPromoted.Should().Be(false); - board["G3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["G3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["G3"]!.IsPromoted.Should().Be(false); - board["H3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["H3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["H3"]!.IsPromoted.Should().Be(false); - board["I3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["I3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["I3"]!.IsPromoted.Should().Be(false); + Assert.Equal(WhichPiece.Pawn, board["A3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["A3"]!.Owner); + Assert.False(board["A3"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["B3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["B3"]!.Owner); + Assert.False(board["B3"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["C3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["C3"]!.Owner); + Assert.False(board["C3"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["D3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["D3"]!.Owner); + Assert.False(board["D3"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["E3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["E3"]!.Owner); + Assert.False(board["E3"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["F3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["F3"]!.Owner); + Assert.False(board["F3"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["G3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["G3"]!.Owner); + Assert.False(board["G3"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["H3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["H3"]!.Owner); + Assert.False(board["H3"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["I3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["I3"]!.Owner); + Assert.False(board["I3"]!.IsPromoted); - board["A4"].Should().BeNull(); - board["B4"].Should().BeNull(); - board["C4"].Should().BeNull(); - board["D4"].Should().BeNull(); - board["E4"].Should().BeNull(); - board["F4"].Should().BeNull(); - board["G4"].Should().BeNull(); - board["H4"].Should().BeNull(); - board["I4"].Should().BeNull(); + Assert.Null(board["A4"]); + Assert.Null(board["B4"]); + Assert.Null(board["C4"]); + Assert.Null(board["D4"]); + Assert.Null(board["E4"]); + Assert.Null(board["F4"]); + Assert.Null(board["G4"]); + Assert.Null(board["H4"]); + Assert.Null(board["I4"]); - board["A5"].Should().BeNull(); - board["B5"].Should().BeNull(); - board["C5"].Should().BeNull(); - board["D5"].Should().BeNull(); - board["E5"].Should().BeNull(); - board["F5"].Should().BeNull(); - board["G5"].Should().BeNull(); - board["H5"].Should().BeNull(); - board["I5"].Should().BeNull(); + Assert.Null(board["A5"]); + Assert.Null(board["B5"]); + Assert.Null(board["C5"]); + Assert.Null(board["D5"]); + Assert.Null(board["E5"]); + Assert.Null(board["F5"]); + Assert.Null(board["G5"]); + Assert.Null(board["H5"]); + Assert.Null(board["I5"]); - board["A6"].Should().BeNull(); - board["B6"].Should().BeNull(); - board["C6"].Should().BeNull(); - board["D6"].Should().BeNull(); - board["E6"].Should().BeNull(); - board["F6"].Should().BeNull(); - board["G6"].Should().BeNull(); - board["H6"].Should().BeNull(); - board["I6"].Should().BeNull(); + Assert.Null(board["A6"]); + Assert.Null(board["B6"]); + Assert.Null(board["C6"]); + Assert.Null(board["D6"]); + Assert.Null(board["E6"]); + Assert.Null(board["F6"]); + Assert.Null(board["G6"]); + Assert.Null(board["H6"]); + Assert.Null(board["I6"]); - board["A7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["A7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["A7"]!.IsPromoted.Should().Be(false); - board["B7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["B7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["B7"]!.IsPromoted.Should().Be(false); - board["C7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["C7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["C7"]!.IsPromoted.Should().Be(false); - board["D7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["D7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["D7"]!.IsPromoted.Should().Be(false); - board["E7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["E7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["E7"]!.IsPromoted.Should().Be(false); - board["F7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["F7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["F7"]!.IsPromoted.Should().Be(false); - board["G7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["G7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["G7"]!.IsPromoted.Should().Be(false); - board["H7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["H7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["H7"]!.IsPromoted.Should().Be(false); - board["I7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["I7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["I7"]!.IsPromoted.Should().Be(false); + Assert.Equal(WhichPiece.Pawn, board["A7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["A7"]!.Owner); + Assert.False(board["A7"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["B7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["B7"]!.Owner); + Assert.False(board["B7"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["C7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["C7"]!.Owner); + Assert.False(board["C7"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["D7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["D7"]!.Owner); + Assert.False(board["D7"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["E7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["E7"]!.Owner); + Assert.False(board["E7"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["F7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["F7"]!.Owner); + Assert.False(board["F7"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["G7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["G7"]!.Owner); + Assert.False(board["G7"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["H7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["H7"]!.Owner); + Assert.False(board["H7"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["I7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["I7"]!.Owner); + Assert.False(board["I7"]!.IsPromoted); - board["A8"].Should().BeNull(); - board["B8"]!.WhichPiece.Should().Be(WhichPiece.Rook); - board["B8"]!.Owner.Should().Be(WhichPlayer.Player2); - board["B8"]!.IsPromoted.Should().Be(false); - board["C8"].Should().BeNull(); - board["D8"].Should().BeNull(); - board["E8"].Should().BeNull(); - board["F8"].Should().BeNull(); - board["G8"].Should().BeNull(); - board["H8"]!.WhichPiece.Should().Be(WhichPiece.Bishop); - board["H8"]!.Owner.Should().Be(WhichPlayer.Player2); - board["H8"]!.IsPromoted.Should().Be(false); - board["I8"].Should().BeNull(); + Assert.Null(board["A8"]); + Assert.Equal(WhichPiece.Rook, board["B8"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["B8"]!.Owner); + Assert.False(board["B8"]!.IsPromoted); + Assert.Null(board["C8"]); + Assert.Null(board["D8"]); + Assert.Null(board["E8"]); + Assert.Null(board["F8"]); + Assert.Null(board["G8"]); + Assert.Equal(WhichPiece.Bishop, board["H8"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["H8"]!.Owner); + Assert.False(board["H8"]!.IsPromoted); + Assert.Null(board["I8"]); - board["A9"]!.WhichPiece.Should().Be(WhichPiece.Lance); - board["A9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["A9"]!.IsPromoted.Should().Be(false); - board["B9"]!.WhichPiece.Should().Be(WhichPiece.Knight); - board["B9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["B9"]!.IsPromoted.Should().Be(false); - board["C9"]!.WhichPiece.Should().Be(WhichPiece.SilverGeneral); - board["C9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["C9"]!.IsPromoted.Should().Be(false); - board["D9"]!.WhichPiece.Should().Be(WhichPiece.GoldGeneral); - board["D9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["D9"]!.IsPromoted.Should().Be(false); - board["E9"]!.WhichPiece.Should().Be(WhichPiece.King); - board["E9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["E9"]!.IsPromoted.Should().Be(false); - board["F9"]!.WhichPiece.Should().Be(WhichPiece.GoldGeneral); - board["F9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["F9"]!.IsPromoted.Should().Be(false); - board["G9"]!.WhichPiece.Should().Be(WhichPiece.SilverGeneral); - board["G9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["G9"]!.IsPromoted.Should().Be(false); - board["H9"]!.WhichPiece.Should().Be(WhichPiece.Knight); - board["H9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["H9"]!.IsPromoted.Should().Be(false); - board["I9"]!.WhichPiece.Should().Be(WhichPiece.Lance); - board["I9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["I9"]!.IsPromoted.Should().Be(false); + Assert.Equal(WhichPiece.Lance, board["A9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["A9"]!.Owner); + Assert.False(board["A9"]!.IsPromoted); + Assert.Equal(WhichPiece.Knight, board["B9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["B9"]!.Owner); + Assert.False(board["B9"]!.IsPromoted); + Assert.Equal(WhichPiece.SilverGeneral, board["C9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["C9"]!.Owner); + Assert.False(board["C9"]!.IsPromoted); + Assert.Equal(WhichPiece.GoldGeneral, board["D9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["D9"]!.Owner); + Assert.False(board["D9"]!.IsPromoted); + Assert.Equal(WhichPiece.King, board["E9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["E9"]!.Owner); + Assert.False(board["E9"]!.IsPromoted); + Assert.Equal(WhichPiece.GoldGeneral, board["F9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["F9"]!.Owner); + Assert.False(board["F9"]!.IsPromoted); + Assert.Equal(WhichPiece.SilverGeneral, board["G9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["G9"]!.Owner); + Assert.False(board["G9"]!.IsPromoted); + Assert.Equal(WhichPiece.Knight, board["H9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["H9"]!.Owner); + Assert.False(board["H9"]!.IsPromoted); + Assert.Equal(WhichPiece.Lance, board["I9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["I9"]!.Owner); + Assert.False(board["I9"]!.IsPromoted); } } @@ -231,12 +225,11 @@ public class ApiTests(AatTestFixture fixture, ITestOutputHelper console) : IClas // Act var readAllResponse = await this.httpClient.GetFromJsonAsync( - new Uri("Sessions", UriKind.Relative), - Contracts.ShogiApiJsonSerializerSettings.SystemTextJsonSerializerOptions); + new Uri("Sessions", UriKind.Relative)); // Assert - readAllResponse.Should().NotBeNull(); - readAllResponse!.First().SessionId.Should().Be(testSession.SessionId); + Assert.NotNull(readAllResponse); + Assert.Equal(testSession.SessionId, readAllResponse!.First().SessionId); } finally { @@ -257,9 +250,9 @@ public class ApiTests(AatTestFixture fixture, ITestOutputHelper console) : IClas var joinResponse = await this.player2HttpClient.PatchAsync(new Uri($"Sessions/{this.sessionId}/Join", UriKind.Relative), null); // Assert - joinResponse.StatusCode.Should().Be(HttpStatusCode.OK); + Assert.Equal(HttpStatusCode.OK, joinResponse.StatusCode); var readSessionResponse = await this.ReadTestSession(); - readSessionResponse.Player2.Should().NotBeNullOrEmpty(); + Assert.False(string.IsNullOrEmpty(readSessionResponse.Player2)); } finally { @@ -275,15 +268,15 @@ public class ApiTests(AatTestFixture fixture, ITestOutputHelper console) : IClas // Arrange await this.SetupTestSession(); var joinResponse = await this.player2HttpClient.PatchAsync(new Uri($"Sessions/{this.sessionId}/Join", UriKind.Relative), null); - joinResponse.StatusCode.Should().Be(HttpStatusCode.OK); + Assert.Equal(HttpStatusCode.OK, joinResponse.StatusCode); var readSessionResponse = await this.ReadTestSession(); - readSessionResponse.Player2.Should().NotBeNull(); + Assert.NotNull(readSessionResponse.Player2); // Act joinResponse = await this.player2HttpClient.PatchAsync(new Uri($"Sessions/{this.sessionId}/Join", UriKind.Relative), null); // Assert - joinResponse.StatusCode.Should().Be(HttpStatusCode.Conflict); + Assert.Equal(HttpStatusCode.Conflict, joinResponse.StatusCode); } finally { @@ -306,16 +299,16 @@ public class ApiTests(AatTestFixture fixture, ITestOutputHelper console) : IClas // Act var response = await this.httpClient.PatchAsync(new Uri($"Sessions/{this.sessionId}/Move", UriKind.Relative), JsonContent.Create(movePawnCommand)); - response.StatusCode.Should().Be(HttpStatusCode.NoContent, because: await response.Content.ReadAsStringAsync()); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); // Assert var session = await this.ReadTestSession(); - session.BoardState.Board.Should().ContainKey("A3"); - session.BoardState.Board["A3"].Should().BeNull(); - session.BoardState.Board["A4"].Should().NotBeNull(); - session.BoardState.Board["A4"]!.IsPromoted.Should().BeFalse(); - session.BoardState.Board["A4"]!.Owner.Should().Be(WhichPlayer.Player1); - session.BoardState.Board["A4"]!.WhichPiece.Should().Be(WhichPiece.Pawn); + Assert.Contains("A3", session.BoardState.Board.Keys); + Assert.Null(session.BoardState.Board["A3"]); + Assert.NotNull(session.BoardState.Board["A4"]); + Assert.False(session.BoardState.Board["A4"]!.IsPromoted); + Assert.Equal(WhichPlayer.Player1, session.BoardState.Board["A4"]!.Owner); + Assert.Equal(WhichPiece.Pawn, session.BoardState.Board["A4"]!.WhichPiece); } finally { @@ -327,7 +320,7 @@ public class ApiTests(AatTestFixture fixture, ITestOutputHelper console) : IClas private async Task SetupTestSession() { var createResponse = await this.httpClient.PostAsync(new Uri("Sessions", UriKind.Relative), null); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); this.sessionId = await createResponse.Content.ReadAsStringAsync(); } @@ -339,7 +332,6 @@ public class ApiTests(AatTestFixture fixture, ITestOutputHelper console) : IClas private async Task DeleteTestSession() { var response = await this.httpClient.DeleteAsync(new Uri($"Sessions/{Uri.EscapeDataString(this.sessionId)}", UriKind.Relative)); - response.StatusCode.Should().Be(HttpStatusCode.NoContent, because: await response.Content.ReadAsStringAsync()); - + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); } -} \ No newline at end of file +} diff --git a/Tests/AcceptanceTests/Shogi.AcceptanceTests.csproj b/Tests/AcceptanceTests/Shogi.AcceptanceTests.csproj index b84ee1c..76f300d 100644 --- a/Tests/AcceptanceTests/Shogi.AcceptanceTests.csproj +++ b/Tests/AcceptanceTests/Shogi.AcceptanceTests.csproj @@ -1,51 +1,31 @@  - net8.0 - enable - enable + net10.0 + enable + enable - false - 96d6281d-a75b-4181-b535-ea34b26dc8a2 - true + false + true - - - - - - - PreserveNewest - - - PreserveNewest - - - - - - - - - - - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/Tests/AcceptanceTests/Shogi.AcceptanceTests.csproj.Backup.tmp b/Tests/AcceptanceTests/Shogi.AcceptanceTests.csproj.Backup.tmp new file mode 100644 index 0000000..60683ba --- /dev/null +++ b/Tests/AcceptanceTests/Shogi.AcceptanceTests.csproj.Backup.tmp @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + + false + 96d6281d-a75b-4181-b535-ea34b26dc8a2 + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Tests/AcceptanceTests/TestSetup/AatTestFixture.cs b/Tests/AcceptanceTests/TestSetup/AatTestFixture.cs index 205b92a..f94477f 100644 --- a/Tests/AcceptanceTests/TestSetup/AatTestFixture.cs +++ b/Tests/AcceptanceTests/TestSetup/AatTestFixture.cs @@ -1,114 +1,109 @@ -using Microsoft.Extensions.Configuration; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shogi.BackEnd.Identity; using System.Net.Http.Json; using System.Text.Json; namespace Shogi.AcceptanceTests.TestSetup; /// -/// Acceptance Test fixture for tests which assert features for Microsoft accounts. +/// Integration test fixture using WebApplicationFactory for in-process testing. /// -public class AatTestFixture : IAsyncLifetime, IDisposable +public class AatTestFixture : WebApplicationFactory, IAsyncLifetime { protected static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); - private readonly string testAccountPassword; - private bool disposedValue; + private const string TestPassword = "TestPassword123!"; - public AatTestFixture() + public HttpClient HttpClient { get; private set; } = null!; + public HttpClient OtherHttpClient { get; private set; } = null!; + + protected override void ConfigureWebHost(IWebHostBuilder builder) { - this.Configuration = new ConfigurationBuilder() - .AddJsonFile("appsettings.json") - .AddJsonFile("appsettings.Development.json", optional: true) - .Build(); + builder.UseEnvironment("Development"); - var baseUrl = this.Configuration["ServiceUrl"] ?? throw new InvalidOperationException(); - this.HttpClient = new HttpClient - { - BaseAddress = new Uri(baseUrl, UriKind.Absolute) - }; - this.OtherHttpClient = new HttpClient - { - BaseAddress = new Uri(baseUrl, UriKind.Absolute) - }; + builder.ConfigureTestServices(services => + { + // Remove all EF Core and database provider related services + var descriptorsToRemove = services + .Where(d => d.ServiceType.Namespace != null && + (d.ServiceType.Namespace.StartsWith("Microsoft.EntityFrameworkCore") || + d.ServiceType == typeof(ApplicationDbContext) || + d.ServiceType == typeof(DbContextOptions))) + .ToList(); - this.testAccountPassword = this.Configuration["TestUserPassword"]!; - if (string.IsNullOrWhiteSpace(this.testAccountPassword)) + foreach (var descriptor in descriptorsToRemove) { - throw new InvalidOperationException("TestUserPassword is not configured."); + services.Remove(descriptor); } - } - - public IConfiguration Configuration { get; private set; } - public HttpClient HttpClient { get; } - public HttpClient OtherHttpClient { get; } - - protected async Task LoginToTestAccounts() - { - var response = await this.HttpClient.PostAsJsonAsync( - RelativeUri("login"), - new - { - email = "aat-account", - password = this.testAccountPassword, - }, - options: SerializerOptions); - - response.IsSuccessStatusCode.Should().BeTrue(because: "The test account should exist. If it does not, use the /Account/TestAccount route to create it."); - - var bearerToken = (await response.Content.ReadFromJsonAsync())?.AccessToken; - this.HttpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearerToken); - - response = await this.HttpClient.PostAsJsonAsync( - RelativeUri("login"), - new - { - email = "aat-account-2", - password = this.testAccountPassword, - }, - options: SerializerOptions); - - response.IsSuccessStatusCode.Should().BeTrue(because: "The test account should exist. If it does not, use the /Account/TestAccount route to create it."); - - bearerToken = (await response.Content.ReadFromJsonAsync())?.AccessToken; - this.OtherHttpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearerToken); + // Add DbContext with InMemory database + services.AddDbContext(options => + options.UseInMemoryDatabase("IntegrationTestDb_" + Guid.NewGuid().ToString())); + }); } public async Task InitializeAsync() { - await this.LoginToTestAccounts(); + this.HttpClient = this.CreateClient(); + this.OtherHttpClient = this.CreateClient(); + + // Ensure the in-memory database is created + using (var scope = this.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + } + + await this.SetupTestAccountsAndLogin(); } - protected virtual void Dispose(bool disposing) + private async Task SetupTestAccountsAndLogin() { - if (!this.disposedValue) - { - if (disposing) - { - this.HttpClient.Dispose(); - } + // Register and login first test account + await RegisterAndLogin(this.HttpClient, "aat-account@test.com", TestPassword); - this.disposedValue = true; + // Register and login second test account + await RegisterAndLogin(this.OtherHttpClient, "aat-account-2@test.com", TestPassword); + } + + private static async Task RegisterAndLogin(HttpClient client, string email, string password) + { + // Try to register (may already exist) + await client.PostAsJsonAsync( + new Uri("register", UriKind.Relative), + new { email, password }, + options: SerializerOptions); + + // Login + var loginResponse = await client.PostAsJsonAsync( + new Uri("login", UriKind.Relative), + new { email, password }, + options: SerializerOptions); + + if (loginResponse.IsSuccessStatusCode) + { + var tokenResponse = await loginResponse.Content.ReadFromJsonAsync(SerializerOptions); + if (tokenResponse?.AccessToken != null) + { + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken); + } } } - public Task DisposeAsync() + async Task IAsyncLifetime.DisposeAsync() { - this.Dispose(true); - return Task.CompletedTask; + this.HttpClient?.Dispose(); + this.OtherHttpClient?.Dispose(); + await base.DisposeAsync(); } - public void Dispose() - { - this.Dispose(disposing: true); - GC.SuppressFinalize(this); - } - - protected static Uri RelativeUri(string s) => new(s, UriKind.Relative); - private class LoginResponse { public string AccessToken { get; set; } = string.Empty; } - } diff --git a/Tests/AcceptanceTests/Usings.cs b/Tests/AcceptanceTests/Usings.cs index 7fef4b0..8c927eb 100644 --- a/Tests/AcceptanceTests/Usings.cs +++ b/Tests/AcceptanceTests/Usings.cs @@ -1,2 +1 @@ -global using Xunit; -global using FluentAssertions; \ No newline at end of file +global using Xunit; \ No newline at end of file diff --git a/Tests/AcceptanceTests/appsettings.Development.json b/Tests/AcceptanceTests/appsettings.Development.json deleted file mode 100644 index f6aa32c..0000000 --- a/Tests/AcceptanceTests/appsettings.Development.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "TestUserPassword": "I'mAToysRUsK1d" -} diff --git a/Tests/AcceptanceTests/appsettings.json b/Tests/AcceptanceTests/appsettings.json deleted file mode 100644 index f217a91..0000000 --- a/Tests/AcceptanceTests/appsettings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "ServiceUrl": "https://localhost:5001", - "TestUserPassword": "" -} \ No newline at end of file diff --git a/Tests/E2ETests/E2ETests.csproj b/Tests/E2ETests/E2ETests.csproj deleted file mode 100644 index d91738a..0000000 --- a/Tests/E2ETests/E2ETests.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - net8.0 - enable - enable - - false - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - diff --git a/Tests/E2ETests/MicrosoftLoginTests.cs b/Tests/E2ETests/MicrosoftLoginTests.cs deleted file mode 100644 index cb17a53..0000000 --- a/Tests/E2ETests/MicrosoftLoginTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.Playwright; -using Microsoft.Playwright.NUnit; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace E2ETests; - -[Parallelizable(ParallelScope.Self)] -[TestFixture] -public class MicrosoftLoginTests : PageTest -{ - - [SetUp] - public async void Init() - { - - } - - [Test] - public async Task Test1() - { - await Page.GotoAsync("https://lucaserver.space/shogi", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); - var loginButton = Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Log in" }); - await loginButton.ClickAsync(); - //await Page.WaitForURLAsync() - } -} \ No newline at end of file diff --git a/Tests/E2ETests/PlaywriteExample.cs b/Tests/E2ETests/PlaywriteExample.cs deleted file mode 100644 index d71891d..0000000 --- a/Tests/E2ETests/PlaywriteExample.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Microsoft.Playwright; -using Microsoft.Playwright.NUnit; -using System.Text.RegularExpressions; - -namespace E2ETests; - -[Parallelizable(ParallelScope.Self)] -[TestFixture] -public class PlaywriteExample : PageTest -{ - [Test] - public async Task HomepageHasPlaywrightInTitleAndGetStartedLinkLinkingtoTheIntroPage() - { - await Page.GotoAsync("https://playwright.dev"); - - // Expect a title "to contain" a substring. - await Expect(Page).ToHaveTitleAsync(new Regex("Playwright")); - - // create a locator - var getStarted = Page.GetByRole(AriaRole.Link, new() { Name = "Get started" }); - - // Expect an attribute "to be strictly equal" to the value. - await Expect(getStarted).ToHaveAttributeAsync("href", "/docs/intro"); - - // Click the get started link. - await getStarted.ClickAsync(); - - // Expects the URL to contain intro. - await Expect(Page).ToHaveURLAsync(new Regex(".*intro")); - } -} \ No newline at end of file diff --git a/Tests/E2ETests/Usings.cs b/Tests/E2ETests/Usings.cs deleted file mode 100644 index cefced4..0000000 --- a/Tests/E2ETests/Usings.cs +++ /dev/null @@ -1 +0,0 @@ -global using NUnit.Framework; \ No newline at end of file diff --git a/Tests/E2ETests/appsettings.json b/Tests/E2ETests/appsettings.json deleted file mode 100644 index 0db3279..0000000 --- a/Tests/E2ETests/appsettings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - -} diff --git a/Tests/UnitTests/Extensions.cs b/Tests/UnitTests/Extensions.cs index f9486c1..1d2e500 100644 --- a/Tests/UnitTests/Extensions.cs +++ b/Tests/UnitTests/Extensions.cs @@ -1,4 +1,6 @@ -using Shogi.Domain.ValueObjects; +using Shogi.BackEnd.Domains.ValueObjects; +using Shogi.BackEnd.Domains.ValueObjects.Pieces; +using Shogi.BackEnd.Domains.ValueObjects.Rules; using System; using System.Text; diff --git a/Tests/UnitTests/NotationShould.cs b/Tests/UnitTests/NotationShould.cs index d42f72f..41b64f1 100644 --- a/Tests/UnitTests/NotationShould.cs +++ b/Tests/UnitTests/NotationShould.cs @@ -1,26 +1,25 @@ -using System.Numerics; -using Shogi.Domain.YetToBeAssimilatedIntoDDD; +using Shogi.BackEnd.Domains.YetToBeAssimilatedIntoDDD; +using System.Numerics; -namespace UnitTests +namespace UnitTests; + +public class NotationShould { - public class NotationShould + [Fact] + public void ConvertFromNotationToVector() { - [Fact] - public void ConvertFromNotationToVector() - { - Notation.FromBoardNotation("A1").Should().Be(new Vector2(0, 0)); - Notation.FromBoardNotation("E5").Should().Be(new Vector2(4, 4)); - Notation.FromBoardNotation("I9").Should().Be(new Vector2(8, 8)); - Notation.FromBoardNotation("C3").Should().Be(new Vector2(2, 2)); - } + Assert.Equal(new Vector2(0, 0), Notation.FromBoardNotation("A1")); + Assert.Equal(new Vector2(4, 4), Notation.FromBoardNotation("E5")); + Assert.Equal(new Vector2(8, 8), Notation.FromBoardNotation("I9")); + Assert.Equal(new Vector2(2, 2), Notation.FromBoardNotation("C3")); + } - [Fact] - public void ConvertFromVectorToNotation() - { - Notation.ToBoardNotation(new Vector2(0, 0)).Should().Be("A1"); - Notation.ToBoardNotation(new Vector2(4, 4)).Should().Be("E5"); - Notation.ToBoardNotation(new Vector2(8, 8)).Should().Be("I9"); - Notation.ToBoardNotation(new Vector2(2, 2)).Should().Be("C3"); - } + [Fact] + public void ConvertFromVectorToNotation() + { + Assert.Equal("A1", Notation.ToBoardNotation(new Vector2(0, 0))); + Assert.Equal("E5", Notation.ToBoardNotation(new Vector2(4, 4))); + Assert.Equal("I9", Notation.ToBoardNotation(new Vector2(8, 8))); + Assert.Equal("C3", Notation.ToBoardNotation(new Vector2(2, 2))); } } diff --git a/Tests/UnitTests/RookShould.cs b/Tests/UnitTests/RookShould.cs index c48b0ea..a759ef5 100644 --- a/Tests/UnitTests/RookShould.cs +++ b/Tests/UnitTests/RookShould.cs @@ -1,5 +1,7 @@ -using Shogi.Domain.ValueObjects; -using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing; +using Shogi.BackEnd.Domains.ValueObjects; +using Shogi.BackEnd.Domains.ValueObjects.Movement; +using Shogi.BackEnd.Domains.ValueObjects.Pieces; +using System.Linq; using System.Numerics; namespace UnitTests; @@ -21,11 +23,11 @@ public class RookShould public void Player1_HasCorrectMoveSet() { var moveSet = rook1.MoveSet; - moveSet.Should().HaveCount(4); - moveSet.Should().ContainEquivalentOf(new Path(Direction.Forward, Distance.MultiStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.Left, Distance.MultiStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.Right, Distance.MultiStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.Backward, Distance.MultiStep)); + Assert.Equal(4, moveSet.Count); + Assert.Contains(new Path(Direction.Forward, Distance.MultiStep), moveSet); + Assert.Contains(new Path(Direction.Left, Distance.MultiStep), moveSet); + Assert.Contains(new Path(Direction.Right, Distance.MultiStep), moveSet); + Assert.Contains(new Path(Direction.Backward, Distance.MultiStep), moveSet); } [Fact] @@ -33,30 +35,30 @@ public class RookShould { // Arrange rook1.Promote(); - rook1.IsPromoted.Should().BeTrue(); + Assert.True(rook1.IsPromoted); // Assert var moveSet = rook1.MoveSet; - moveSet.Should().HaveCount(8); - moveSet.Should().ContainEquivalentOf(new Path(Direction.Forward, Distance.MultiStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.Left, Distance.MultiStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.Right, Distance.MultiStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.Backward, Distance.MultiStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.ForwardLeft, Distance.OneStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.BackwardLeft, Distance.OneStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.ForwardRight, Distance.OneStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.BackwardRight, Distance.OneStep)); + Assert.Equal(8, moveSet.Count); + Assert.Contains(new Path(Direction.Forward, Distance.MultiStep), moveSet); + Assert.Contains(new Path(Direction.Left, Distance.MultiStep), moveSet); + Assert.Contains(new Path(Direction.Right, Distance.MultiStep), moveSet); + Assert.Contains(new Path(Direction.Backward, Distance.MultiStep), moveSet); + Assert.Contains(new Path(Direction.ForwardLeft, Distance.OneStep), moveSet); + Assert.Contains(new Path(Direction.BackwardLeft, Distance.OneStep), moveSet); + Assert.Contains(new Path(Direction.ForwardRight, Distance.OneStep), moveSet); + Assert.Contains(new Path(Direction.BackwardRight, Distance.OneStep), moveSet); } [Fact] public void Player2_HasCorrectMoveSet() { var moveSet = rook2.MoveSet; - moveSet.Should().HaveCount(4); - moveSet.Should().ContainEquivalentOf(new Path(Direction.Forward, Distance.MultiStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.Left, Distance.MultiStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.Right, Distance.MultiStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.Backward, Distance.MultiStep)); + Assert.Equal(4, moveSet.Count); + Assert.Contains(new Path(Direction.Forward, Distance.MultiStep), moveSet); + Assert.Contains(new Path(Direction.Left, Distance.MultiStep), moveSet); + Assert.Contains(new Path(Direction.Right, Distance.MultiStep), moveSet); + Assert.Contains(new Path(Direction.Backward, Distance.MultiStep), moveSet); } [Fact] @@ -64,23 +66,22 @@ public class RookShould { // Arrange rook2.Promote(); - rook2.IsPromoted.Should().BeTrue(); + Assert.True(rook2.IsPromoted); // Assert var moveSet = rook2.MoveSet; - moveSet.Should().HaveCount(8); - moveSet.Should().ContainEquivalentOf(new Path(Direction.Forward, Distance.MultiStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.Left, Distance.MultiStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.Right, Distance.MultiStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.Backward, Distance.MultiStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.ForwardLeft, Distance.OneStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.BackwardLeft, Distance.OneStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.ForwardRight, Distance.OneStep)); - moveSet.Should().ContainEquivalentOf(new Path(Direction.BackwardRight, Distance.OneStep)); + Assert.Equal(8, moveSet.Count); + Assert.Contains(new Path(Direction.Forward, Distance.MultiStep), moveSet); + Assert.Contains(new Path(Direction.Left, Distance.MultiStep), moveSet); + Assert.Contains(new Path(Direction.Right, Distance.MultiStep), moveSet); + Assert.Contains(new Path(Direction.Backward, Distance.MultiStep), moveSet); + Assert.Contains(new Path(Direction.ForwardLeft, Distance.OneStep), moveSet); + Assert.Contains(new Path(Direction.BackwardLeft, Distance.OneStep), moveSet); + Assert.Contains(new Path(Direction.ForwardRight, Distance.OneStep), moveSet); + Assert.Contains(new Path(Direction.BackwardRight, Distance.OneStep), moveSet); } - - } + private readonly Rook rookPlayer1; public RookShould() @@ -91,11 +92,11 @@ public class RookShould [Fact] public void Promote() { - rookPlayer1.IsPromoted.Should().BeFalse(); - rookPlayer1.CanPromote.Should().BeTrue(); + Assert.False(rookPlayer1.IsPromoted); + Assert.True(rookPlayer1.CanPromote); rookPlayer1.Promote(); - rookPlayer1.IsPromoted.Should().BeTrue(); - rookPlayer1.CanPromote.Should().BeFalse(); + Assert.True(rookPlayer1.IsPromoted); + Assert.False(rookPlayer1.CanPromote); } [Fact] @@ -104,15 +105,15 @@ public class RookShould Vector2 start = new(0, 0); Vector2 end = new(0, 5); - var steps = rookPlayer1.GetPathFromStartToEnd(start, end); + var steps = rookPlayer1.GetPathFromStartToEnd(start, end).ToList(); - rookPlayer1.IsPromoted.Should().BeFalse(); - steps.Should().HaveCount(5); - steps.Should().Contain(new Vector2(0, 1)); - steps.Should().Contain(new Vector2(0, 2)); - steps.Should().Contain(new Vector2(0, 3)); - steps.Should().Contain(new Vector2(0, 4)); - steps.Should().Contain(new Vector2(0, 5)); + Assert.False(rookPlayer1.IsPromoted); + Assert.Equal(5, steps.Count); + Assert.Contains(new Vector2(0, 1), steps); + Assert.Contains(new Vector2(0, 2), steps); + Assert.Contains(new Vector2(0, 3), steps); + Assert.Contains(new Vector2(0, 4), steps); + Assert.Contains(new Vector2(0, 5), steps); } [Fact] @@ -121,10 +122,10 @@ public class RookShould Vector2 start = new(0, 0); Vector2 end = new(1, 1); - var steps = rookPlayer1.GetPathFromStartToEnd(start, end); + var steps = rookPlayer1.GetPathFromStartToEnd(start, end).ToList(); - rookPlayer1.IsPromoted.Should().BeFalse(); - steps.Should().BeEmpty(); + Assert.False(rookPlayer1.IsPromoted); + Assert.Empty(steps); } [Fact] @@ -134,15 +135,15 @@ public class RookShould Vector2 end = new(0, 5); rookPlayer1.Promote(); - var steps = rookPlayer1.GetPathFromStartToEnd(start, end); + var steps = rookPlayer1.GetPathFromStartToEnd(start, end).ToList(); - rookPlayer1.IsPromoted.Should().BeTrue(); - steps.Should().HaveCount(5); - steps.Should().Contain(new Vector2(0, 1)); - steps.Should().Contain(new Vector2(0, 2)); - steps.Should().Contain(new Vector2(0, 3)); - steps.Should().Contain(new Vector2(0, 4)); - steps.Should().Contain(new Vector2(0, 5)); + Assert.True(rookPlayer1.IsPromoted); + Assert.Equal(5, steps.Count); + Assert.Contains(new Vector2(0, 1), steps); + Assert.Contains(new Vector2(0, 2), steps); + Assert.Contains(new Vector2(0, 3), steps); + Assert.Contains(new Vector2(0, 4), steps); + Assert.Contains(new Vector2(0, 5), steps); } [Fact] @@ -152,11 +153,11 @@ public class RookShould Vector2 end = new(1, 1); rookPlayer1.Promote(); - var steps = rookPlayer1.GetPathFromStartToEnd(start, end); + var steps = rookPlayer1.GetPathFromStartToEnd(start, end).ToList(); - rookPlayer1.IsPromoted.Should().BeTrue(); - steps.Should().HaveCount(1); - steps.Should().Contain(new Vector2(1, 1)); + Assert.True(rookPlayer1.IsPromoted); + Assert.Single(steps); + Assert.Contains(new Vector2(1, 1), steps); } [Fact] @@ -165,15 +166,15 @@ public class RookShould Vector2 start = new(0, 0); Vector2 end = new(0, 5); - var steps = rookPlayer1.GetPathFromStartToEnd(start, end); + var steps = rookPlayer1.GetPathFromStartToEnd(start, end).ToList(); - rookPlayer1.IsPromoted.Should().BeFalse(); - steps.Should().HaveCount(5); - steps.Should().Contain(new Vector2(0, 1)); - steps.Should().Contain(new Vector2(0, 2)); - steps.Should().Contain(new Vector2(0, 3)); - steps.Should().Contain(new Vector2(0, 4)); - steps.Should().Contain(new Vector2(0, 5)); + Assert.False(rookPlayer1.IsPromoted); + Assert.Equal(5, steps.Count); + Assert.Contains(new Vector2(0, 1), steps); + Assert.Contains(new Vector2(0, 2), steps); + Assert.Contains(new Vector2(0, 3), steps); + Assert.Contains(new Vector2(0, 4), steps); + Assert.Contains(new Vector2(0, 5), steps); } [Fact] @@ -182,10 +183,10 @@ public class RookShould Vector2 start = new(0, 0); Vector2 end = new(1, 1); - var steps = rookPlayer1.GetPathFromStartToEnd(start, end); + var steps = rookPlayer1.GetPathFromStartToEnd(start, end).ToList(); - rookPlayer1.IsPromoted.Should().BeFalse(); - steps.Should().BeEmpty(); + Assert.False(rookPlayer1.IsPromoted); + Assert.Empty(steps); } [Fact] @@ -195,15 +196,15 @@ public class RookShould Vector2 end = new(0, 5); rookPlayer1.Promote(); - var steps = rookPlayer1.GetPathFromStartToEnd(start, end); + var steps = rookPlayer1.GetPathFromStartToEnd(start, end).ToList(); - rookPlayer1.IsPromoted.Should().BeTrue(); - steps.Should().HaveCount(5); - steps.Should().Contain(new Vector2(0, 1)); - steps.Should().Contain(new Vector2(0, 2)); - steps.Should().Contain(new Vector2(0, 3)); - steps.Should().Contain(new Vector2(0, 4)); - steps.Should().Contain(new Vector2(0, 5)); + Assert.True(rookPlayer1.IsPromoted); + Assert.Equal(5, steps.Count); + Assert.Contains(new Vector2(0, 1), steps); + Assert.Contains(new Vector2(0, 2), steps); + Assert.Contains(new Vector2(0, 3), steps); + Assert.Contains(new Vector2(0, 4), steps); + Assert.Contains(new Vector2(0, 5), steps); } [Fact] @@ -213,10 +214,10 @@ public class RookShould Vector2 end = new(1, 1); rookPlayer1.Promote(); - var steps = rookPlayer1.GetPathFromStartToEnd(start, end); + var steps = rookPlayer1.GetPathFromStartToEnd(start, end).ToList(); - rookPlayer1.IsPromoted.Should().BeTrue(); - steps.Should().HaveCount(1); - steps.Should().Contain(new Vector2(1, 1)); + Assert.True(rookPlayer1.IsPromoted); + Assert.Single(steps); + Assert.Contains(new Vector2(1, 1), steps); } } diff --git a/Tests/UnitTests/ShogiBoardStateShould.cs b/Tests/UnitTests/ShogiBoardStateShould.cs index a4488e8..404e01d 100644 --- a/Tests/UnitTests/ShogiBoardStateShould.cs +++ b/Tests/UnitTests/ShogiBoardStateShould.cs @@ -1,4 +1,4 @@ -using Shogi.Domain.ValueObjects; +using Shogi.BackEnd.Domains.ValueObjects; namespace UnitTests; @@ -11,174 +11,174 @@ public class ShogiBoardStateShould var board = BoardState.StandardStarting; // Assert - board["A1"]!.WhichPiece.Should().Be(WhichPiece.Lance); - board["A1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["A1"]!.IsPromoted.Should().Be(false); - board["B1"]!.WhichPiece.Should().Be(WhichPiece.Knight); - board["B1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["B1"]!.IsPromoted.Should().Be(false); - board["C1"]!.WhichPiece.Should().Be(WhichPiece.SilverGeneral); - board["C1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["C1"]!.IsPromoted.Should().Be(false); - board["D1"]!.WhichPiece.Should().Be(WhichPiece.GoldGeneral); - board["D1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["D1"]!.IsPromoted.Should().Be(false); - board["E1"]!.WhichPiece.Should().Be(WhichPiece.King); - board["E1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["E1"]!.IsPromoted.Should().Be(false); - board["F1"]!.WhichPiece.Should().Be(WhichPiece.GoldGeneral); - board["F1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["F1"]!.IsPromoted.Should().Be(false); - board["G1"]!.WhichPiece.Should().Be(WhichPiece.SilverGeneral); - board["G1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["G1"]!.IsPromoted.Should().Be(false); - board["H1"]!.WhichPiece.Should().Be(WhichPiece.Knight); - board["H1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["H1"]!.IsPromoted.Should().Be(false); - board["I1"]!.WhichPiece.Should().Be(WhichPiece.Lance); - board["I1"]!.Owner.Should().Be(WhichPlayer.Player1); - board["I1"]!.IsPromoted.Should().Be(false); + Assert.Equal(WhichPiece.Lance, board["A1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["A1"]!.Owner); + Assert.False(board["A1"]!.IsPromoted); + Assert.Equal(WhichPiece.Knight, board["B1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["B1"]!.Owner); + Assert.False(board["B1"]!.IsPromoted); + Assert.Equal(WhichPiece.SilverGeneral, board["C1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["C1"]!.Owner); + Assert.False(board["C1"]!.IsPromoted); + Assert.Equal(WhichPiece.GoldGeneral, board["D1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["D1"]!.Owner); + Assert.False(board["D1"]!.IsPromoted); + Assert.Equal(WhichPiece.King, board["E1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["E1"]!.Owner); + Assert.False(board["E1"]!.IsPromoted); + Assert.Equal(WhichPiece.GoldGeneral, board["F1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["F1"]!.Owner); + Assert.False(board["F1"]!.IsPromoted); + Assert.Equal(WhichPiece.SilverGeneral, board["G1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["G1"]!.Owner); + Assert.False(board["G1"]!.IsPromoted); + Assert.Equal(WhichPiece.Knight, board["H1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["H1"]!.Owner); + Assert.False(board["H1"]!.IsPromoted); + Assert.Equal(WhichPiece.Lance, board["I1"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["I1"]!.Owner); + Assert.False(board["I1"]!.IsPromoted); - board["A2"].Should().BeNull(); - board["B2"]!.WhichPiece.Should().Be(WhichPiece.Bishop); - board["B2"]!.Owner.Should().Be(WhichPlayer.Player1); - board["B2"]!.IsPromoted.Should().Be(false); - board["C2"].Should().BeNull(); - board["D2"].Should().BeNull(); - board["E2"].Should().BeNull(); - board["F2"].Should().BeNull(); - board["G2"].Should().BeNull(); - board["H2"]!.WhichPiece.Should().Be(WhichPiece.Rook); - board["H2"]!.Owner.Should().Be(WhichPlayer.Player1); - board["H2"]!.IsPromoted.Should().Be(false); - board["I2"].Should().BeNull(); + Assert.Null(board["A2"]); + Assert.Equal(WhichPiece.Bishop, board["B2"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["B2"]!.Owner); + Assert.False(board["B2"]!.IsPromoted); + Assert.Null(board["C2"]); + Assert.Null(board["D2"]); + Assert.Null(board["E2"]); + Assert.Null(board["F2"]); + Assert.Null(board["G2"]); + Assert.Equal(WhichPiece.Rook, board["H2"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["H2"]!.Owner); + Assert.False(board["H2"]!.IsPromoted); + Assert.Null(board["I2"]); - board["A3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["A3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["A3"]!.IsPromoted.Should().Be(false); - board["B3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["B3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["B3"]!.IsPromoted.Should().Be(false); - board["C3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["C3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["C3"]!.IsPromoted.Should().Be(false); - board["D3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["D3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["D3"]!.IsPromoted.Should().Be(false); - board["E3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["E3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["E3"]!.IsPromoted.Should().Be(false); - board["F3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["F3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["F3"]!.IsPromoted.Should().Be(false); - board["G3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["G3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["G3"]!.IsPromoted.Should().Be(false); - board["H3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["H3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["H3"]!.IsPromoted.Should().Be(false); - board["I3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["I3"]!.Owner.Should().Be(WhichPlayer.Player1); - board["I3"]!.IsPromoted.Should().Be(false); + Assert.Equal(WhichPiece.Pawn, board["A3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["A3"]!.Owner); + Assert.False(board["A3"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["B3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["B3"]!.Owner); + Assert.False(board["B3"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["C3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["C3"]!.Owner); + Assert.False(board["C3"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["D3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["D3"]!.Owner); + Assert.False(board["D3"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["E3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["E3"]!.Owner); + Assert.False(board["E3"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["F3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["F3"]!.Owner); + Assert.False(board["F3"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["G3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["G3"]!.Owner); + Assert.False(board["G3"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["H3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["H3"]!.Owner); + Assert.False(board["H3"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["I3"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["I3"]!.Owner); + Assert.False(board["I3"]!.IsPromoted); - board["A4"].Should().BeNull(); - board["B4"].Should().BeNull(); - board["C4"].Should().BeNull(); - board["D4"].Should().BeNull(); - board["E4"].Should().BeNull(); - board["F4"].Should().BeNull(); - board["G4"].Should().BeNull(); - board["H4"].Should().BeNull(); - board["I4"].Should().BeNull(); + Assert.Null(board["A4"]); + Assert.Null(board["B4"]); + Assert.Null(board["C4"]); + Assert.Null(board["D4"]); + Assert.Null(board["E4"]); + Assert.Null(board["F4"]); + Assert.Null(board["G4"]); + Assert.Null(board["H4"]); + Assert.Null(board["I4"]); - board["A5"].Should().BeNull(); - board["B5"].Should().BeNull(); - board["C5"].Should().BeNull(); - board["D5"].Should().BeNull(); - board["E5"].Should().BeNull(); - board["F5"].Should().BeNull(); - board["G5"].Should().BeNull(); - board["H5"].Should().BeNull(); - board["I5"].Should().BeNull(); + Assert.Null(board["A5"]); + Assert.Null(board["B5"]); + Assert.Null(board["C5"]); + Assert.Null(board["D5"]); + Assert.Null(board["E5"]); + Assert.Null(board["F5"]); + Assert.Null(board["G5"]); + Assert.Null(board["H5"]); + Assert.Null(board["I5"]); - board["A6"].Should().BeNull(); - board["B6"].Should().BeNull(); - board["C6"].Should().BeNull(); - board["D6"].Should().BeNull(); - board["E6"].Should().BeNull(); - board["F6"].Should().BeNull(); - board["G6"].Should().BeNull(); - board["H6"].Should().BeNull(); - board["I6"].Should().BeNull(); + Assert.Null(board["A6"]); + Assert.Null(board["B6"]); + Assert.Null(board["C6"]); + Assert.Null(board["D6"]); + Assert.Null(board["E6"]); + Assert.Null(board["F6"]); + Assert.Null(board["G6"]); + Assert.Null(board["H6"]); + Assert.Null(board["I6"]); - board["A7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["A7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["A7"]!.IsPromoted.Should().Be(false); - board["B7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["B7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["B7"]!.IsPromoted.Should().Be(false); - board["C7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["C7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["C7"]!.IsPromoted.Should().Be(false); - board["D7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["D7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["D7"]!.IsPromoted.Should().Be(false); - board["E7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["E7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["E7"]!.IsPromoted.Should().Be(false); - board["F7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["F7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["F7"]!.IsPromoted.Should().Be(false); - board["G7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["G7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["G7"]!.IsPromoted.Should().Be(false); - board["H7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["H7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["H7"]!.IsPromoted.Should().Be(false); - board["I7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); - board["I7"]!.Owner.Should().Be(WhichPlayer.Player2); - board["I7"]!.IsPromoted.Should().Be(false); + Assert.Equal(WhichPiece.Pawn, board["A7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["A7"]!.Owner); + Assert.False(board["A7"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["B7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["B7"]!.Owner); + Assert.False(board["B7"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["C7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["C7"]!.Owner); + Assert.False(board["C7"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["D7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["D7"]!.Owner); + Assert.False(board["D7"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["E7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["E7"]!.Owner); + Assert.False(board["E7"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["F7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["F7"]!.Owner); + Assert.False(board["F7"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["G7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["G7"]!.Owner); + Assert.False(board["G7"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["H7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["H7"]!.Owner); + Assert.False(board["H7"]!.IsPromoted); + Assert.Equal(WhichPiece.Pawn, board["I7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["I7"]!.Owner); + Assert.False(board["I7"]!.IsPromoted); - board["A8"].Should().BeNull(); - board["B8"]!.WhichPiece.Should().Be(WhichPiece.Rook); - board["B8"]!.Owner.Should().Be(WhichPlayer.Player2); - board["B8"]!.IsPromoted.Should().Be(false); - board["C8"].Should().BeNull(); - board["D8"].Should().BeNull(); - board["E8"].Should().BeNull(); - board["F8"].Should().BeNull(); - board["G8"].Should().BeNull(); - board["H8"]!.WhichPiece.Should().Be(WhichPiece.Bishop); - board["H8"]!.Owner.Should().Be(WhichPlayer.Player2); - board["H8"]!.IsPromoted.Should().Be(false); - board["I8"].Should().BeNull(); + Assert.Null(board["A8"]); + Assert.Equal(WhichPiece.Rook, board["B8"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["B8"]!.Owner); + Assert.False(board["B8"]!.IsPromoted); + Assert.Null(board["C8"]); + Assert.Null(board["D8"]); + Assert.Null(board["E8"]); + Assert.Null(board["F8"]); + Assert.Null(board["G8"]); + Assert.Equal(WhichPiece.Bishop, board["H8"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["H8"]!.Owner); + Assert.False(board["H8"]!.IsPromoted); + Assert.Null(board["I8"]); - board["A9"]!.WhichPiece.Should().Be(WhichPiece.Lance); - board["A9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["A9"]!.IsPromoted.Should().Be(false); - board["B9"]!.WhichPiece.Should().Be(WhichPiece.Knight); - board["B9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["B9"]!.IsPromoted.Should().Be(false); - board["C9"]!.WhichPiece.Should().Be(WhichPiece.SilverGeneral); - board["C9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["C9"]!.IsPromoted.Should().Be(false); - board["D9"]!.WhichPiece.Should().Be(WhichPiece.GoldGeneral); - board["D9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["D9"]!.IsPromoted.Should().Be(false); - board["E9"]!.WhichPiece.Should().Be(WhichPiece.King); - board["E9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["E9"]!.IsPromoted.Should().Be(false); - board["F9"]!.WhichPiece.Should().Be(WhichPiece.GoldGeneral); - board["F9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["F9"]!.IsPromoted.Should().Be(false); - board["G9"]!.WhichPiece.Should().Be(WhichPiece.SilverGeneral); - board["G9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["G9"]!.IsPromoted.Should().Be(false); - board["H9"]!.WhichPiece.Should().Be(WhichPiece.Knight); - board["H9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["H9"]!.IsPromoted.Should().Be(false); - board["I9"]!.WhichPiece.Should().Be(WhichPiece.Lance); - board["I9"]!.Owner.Should().Be(WhichPlayer.Player2); - board["I9"]!.IsPromoted.Should().Be(false); + Assert.Equal(WhichPiece.Lance, board["A9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["A9"]!.Owner); + Assert.False(board["A9"]!.IsPromoted); + Assert.Equal(WhichPiece.Knight, board["B9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["B9"]!.Owner); + Assert.False(board["B9"]!.IsPromoted); + Assert.Equal(WhichPiece.SilverGeneral, board["C9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["C9"]!.Owner); + Assert.False(board["C9"]!.IsPromoted); + Assert.Equal(WhichPiece.GoldGeneral, board["D9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["D9"]!.Owner); + Assert.False(board["D9"]!.IsPromoted); + Assert.Equal(WhichPiece.King, board["E9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["E9"]!.Owner); + Assert.False(board["E9"]!.IsPromoted); + Assert.Equal(WhichPiece.GoldGeneral, board["F9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["F9"]!.Owner); + Assert.False(board["F9"]!.IsPromoted); + Assert.Equal(WhichPiece.SilverGeneral, board["G9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["G9"]!.Owner); + Assert.False(board["G9"]!.IsPromoted); + Assert.Equal(WhichPiece.Knight, board["H9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["H9"]!.Owner); + Assert.False(board["H9"]!.IsPromoted); + Assert.Equal(WhichPiece.Lance, board["I9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, board["I9"]!.Owner); + Assert.False(board["I9"]!.IsPromoted); } } diff --git a/Tests/UnitTests/ShogiShould.cs b/Tests/UnitTests/ShogiShould.cs index 064e303..0ea9fa2 100644 --- a/Tests/UnitTests/ShogiShould.cs +++ b/Tests/UnitTests/ShogiShould.cs @@ -1,460 +1,427 @@ -using Shogi.Domain.ValueObjects; -using System; -using System.Linq; +using Shogi.BackEnd.Domains.ValueObjects; +using Shogi.BackEnd.Domains.ValueObjects.Rules; -namespace UnitTests +namespace UnitTests; + +public class ShogiShould { - public class ShogiShould + [Fact] + public void MoveAPieceToAnEmptyPosition() { - private readonly ITestOutputHelper console; - public ShogiShould(ITestOutputHelper console) - { - this.console = console; - } + // Arrange + var shogi = MockShogiBoard(); + var board = shogi.BoardState; - [Fact] - public void MoveAPieceToAnEmptyPosition() - { - // Arrange - var shogi = MockShogiBoard(); - var board = shogi.BoardState; + Assert.Null(board["A4"]); + var expectedPiece = board["A3"]; + Assert.NotNull(expectedPiece); - board["A4"].Should().BeNull(); - var expectedPiece = board["A3"]; - expectedPiece.Should().NotBeNull(); + // Act + shogi.Move("A3", "A4", false); - // Act - shogi.Move("A3", "A4", false); - - // Assert - board["A3"].Should().BeNull(); - board["A4"].Should().Be(expectedPiece); - } - - [Fact] - public void AllowValidMoves_AfterCheck() - { - // Arrange - var shogi = MockShogiBoard(); - var board = shogi.BoardState; - // P1 Pawn - shogi.Move("C3", "C4", false); - // P2 Pawn - shogi.Move("G7", "G6", false); - // P1 Bishop puts P2 in check - shogi.Move("B2", "G7", false); - board.InCheck.Should().Be(WhichPlayer.Player2); - - // Act - P2 is able to un-check theirself. - /// P2 King moves out of check - shogi.Move("E9", "E8", false); - - // Assert - using (new AssertionScope()) - { - board.InCheck.Should().BeNull(); - } - } - - [Fact] - public void PreventInvalidMoves_MoveFromEmptyPosition() - { - // Arrange - var shogi = MockShogiBoard(); - var board = shogi.BoardState; - board["D5"].Should().BeNull(); - - // Act - var moveResult = shogi.Move("D5", "D6", false); - - // Assert - moveResult.Should().NotBeNull(); - moveResult.IsSuccess.Should().BeFalse(); - board["D5"].Should().BeNull(); - board["D6"].Should().BeNull(); - board.Player1Hand.Should().BeEmpty(); - board.Player2Hand.Should().BeEmpty(); - } - - [Fact] - public void PreventInvalidMoves_MoveToCurrentPosition() - { - // Arrange - var shogi = MockShogiBoard(); - var board = shogi.BoardState; - var expectedPiece = board["A3"]; - - // Act - P1 "moves" pawn to the position it already exists at. - var moveResult = shogi.Move("A3", "A3", false); - - // Assert - using (new AssertionScope()) - { - moveResult.Should().NotBeNull(); - moveResult.IsSuccess.Should().BeFalse(); board["A3"].Should().Be(expectedPiece); - board.Player1Hand.Should().BeEmpty(); - board.Player2Hand.Should().BeEmpty(); - } - } - - [Fact] - public void PreventInvalidMoves_MoveSet() - { - // Arrange - var shogi = MockShogiBoard(); - var board = shogi.BoardState; - var expectedPiece = board["D1"]; - expectedPiece!.WhichPiece.Should().Be(WhichPiece.GoldGeneral); - - // Act - Move General illegally - var moveResult = shogi.Move("D1", "D5", false); - - // Assert - using (new AssertionScope()) - { - moveResult.Should().NotBeNull(); - moveResult.IsSuccess.Should().BeFalse(); - board["D1"].Should().Be(expectedPiece); - board["D5"].Should().BeNull(); - board.Player1Hand.Should().BeEmpty(); - board.Player2Hand.Should().BeEmpty(); - } - } - - [Fact] - public void PreventInvalidMoves_Ownership() - { - // Arrange - var shogi = MockShogiBoard(); - var board = shogi.BoardState; - var expectedPiece = board["A7"]; - expectedPiece!.Owner.Should().Be(WhichPlayer.Player2); - board.WhoseTurn.Should().Be(WhichPlayer.Player1); - - // Act - Move Player2 Pawn when it is Player1 turn. - var moveResult = shogi.Move("A7", "A6", false); - - // Assert - using (new AssertionScope()) - { - moveResult.Should().NotBeNull(); - moveResult.IsSuccess.Should().BeFalse(); board["A7"].Should().Be(expectedPiece); - board["A6"].Should().BeNull(); - } - } - - [Fact] - public void PreventInvalidMoves_MoveThroughAllies() - { - // Arrange - var shogi = MockShogiBoard(); - var board = shogi.BoardState; - var lance = board["A1"]; - var pawn = board["A3"]; - lance!.Owner.Should().Be(pawn!.Owner); - - // Act - Move P1 Lance through P1 Pawn. - var moveResult = shogi.Move("A1", "A5", false); - - // Assert - using (new AssertionScope()) - { - moveResult.Should().NotBeNull(); - moveResult.IsSuccess.Should().BeFalse(); board["A1"].Should().Be(lance); - board["A3"].Should().Be(pawn); - board["A5"].Should().BeNull(); - } - } - - [Fact] - public void PreventInvalidMoves_CaptureAlly() - { - // Arrange - var shogi = MockShogiBoard(); - var board = shogi.BoardState; - var knight = board["B1"]; - var pawn = board["C3"]; - knight!.Owner.Should().Be(pawn!.Owner); - - // Act - P1 Knight tries to capture P1 Pawn. - var moveResult = shogi.Move("B1", "C3", false); - - // Arrange - using (new AssertionScope()) - { - moveResult.Should().NotBeNull(); - moveResult.IsSuccess.Should().BeFalse(); board["B1"].Should().Be(knight); - board["C3"].Should().Be(pawn); - board.Player1Hand.Should().BeEmpty(); - board.Player2Hand.Should().BeEmpty(); - } - } - - [Fact] - public void PreventInvalidMoves_Check() - { - // Arrange - var shogi = MockShogiBoard(); - var board = shogi.BoardState; - // P1 Pawn - shogi.Move("C3", "C4", false); - // P2 Pawn - shogi.Move("G7", "G6", false); - // P1 Bishop puts P2 in check - shogi.Move("B2", "G7", false); - board.InCheck.Should().Be(WhichPlayer.Player2); - var lance = board["I9"]; - - // Act - P2 moves Lance while in check. - var moveResult = shogi.Move("I9", "I8", false); - - // Assert - using (new AssertionScope()) - { - moveResult.Should().NotBeNull(); - moveResult.IsSuccess.Should().BeFalse(); board.InCheck.Should().Be(WhichPlayer.Player2); - board["I9"].Should().Be(lance); - board["I8"].Should().BeNull(); - } - } - - [Fact] - public void PreventInvalidDrops_MoveSet() - { - // Arrange - var shogi = MockShogiBoard(); - var board = shogi.BoardState; - // P1 Pawn - shogi.Move("C3", "C4", false); - // P2 Pawn - shogi.Move("I7", "I6", false); - // P1 Bishop takes P2 Pawn. - shogi.Move("B2", "G7", false); - // P2 Gold, block check from P1 Bishop. - shogi.Move("F9", "F8", false); - // P1 Bishop takes P2 Bishop, promotes so it can capture P2 Knight and P2 Lance - shogi.Move("G7", "H8", true); - // P2 Pawn again - shogi.Move("I6", "I5", false); - // P1 Bishop takes P2 Knight - shogi.Move("H8", "H9", false); - // P2 Pawn again - shogi.Move("I5", "I4", false); - // P1 Bishop takes P2 Lance - shogi.Move("H9", "I9", false); - // P2 Pawn captures P1 Pawn - shogi.Move("I4", "I3", false); - board.Player1Hand.Count.Should().Be(4); - board.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Knight); - board.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Lance); - board.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Pawn); - board.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop); - board.WhoseTurn.Should().Be(WhichPlayer.Player1); - - // Act | Assert - Illegally placing Knight from the hand in farthest rank. - board["H9"].Should().BeNull(); - var moveResult = shogi.Move(WhichPiece.Knight, "H9"); - moveResult.Should().NotBeNull(); - moveResult.IsSuccess.Should().BeFalse(); - board["H9"].Should().BeNull(); - board.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Knight); - - // Act | Assert - Illegally placing Knight from the hand in second farthest row. - board["H8"].Should().BeNull(); - moveResult = shogi.Move(WhichPiece.Knight, "H8"); - moveResult.Should().NotBeNull(); - moveResult.IsSuccess.Should().BeFalse(); - board["H8"].Should().BeNull(); - board.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Knight); - - // Act | Assert - Illegally place Lance from the hand. - board["H9"].Should().BeNull(); - moveResult = shogi.Move(WhichPiece.Knight, "H9"); - moveResult.Should().NotBeNull(); - moveResult.IsSuccess.Should().BeFalse(); - board["H9"].Should().BeNull(); - board.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Lance); - - // Act | Assert - Illegally place Pawn from the hand. - board["H9"].Should().BeNull(); - moveResult = shogi.Move(WhichPiece.Pawn, "H9"); - moveResult.Should().NotBeNull(); - moveResult.IsSuccess.Should().BeFalse(); - board["H9"].Should().BeNull(); - board.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Pawn); - - // // Act | Assert - Illegally place Pawn from the hand in a row which already has an unpromoted Pawn. - // // TODO - } - - [Fact] - public void PreventInvalidDrop_Check() - { - // Arrange - var shogi = MockShogiBoard(); - // P1 Pawn - shogi.Move("C3", "C4", false).IsSuccess.Should().BeTrue(); - // P2 Pawn - shogi.Move("G7", "G6", false).IsSuccess.Should().BeTrue(); - // P1 Pawn, arbitrary move. - shogi.Move("A3", "A4", false).IsSuccess.Should().BeTrue(); - // P2 Bishop takes P1 Bishop - shogi.Move("H8", "B2", false).IsSuccess.Should().BeTrue(); - // P1 Silver takes P2 Bishop - shogi.Move("C1", "B2", false).IsSuccess.Should().BeTrue(); - // P2 Pawn, arbtrary move - shogi.Move("A7", "A6", false).IsSuccess.Should().BeTrue(); - // P1 drop Bishop, place P2 in check - shogi.Move(WhichPiece.Bishop, "G7").IsSuccess.Should().BeTrue(); - shogi.BoardState.InCheck.Should().Be(WhichPlayer.Player2); - shogi.BoardState.Player2Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop); - shogi.BoardState["E5"].Should().BeNull(); - - // Act - P2 places a Bishop while in check. - var moveResult = shogi.Move(WhichPiece.Bishop, "E5"); - - // Assert - using var scope = new AssertionScope(); - moveResult.Should().NotBeNull(); - moveResult.IsSuccess.Should().BeFalse(); - shogi.BoardState["E5"].Should().BeNull(); - shogi.BoardState.InCheck.Should().Be(WhichPlayer.Player2); - shogi.BoardState.Player2Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop); - } - - [Fact] - public void PreventInvalidDrop_Capture() - { - // Arrange - var shogi = MockShogiBoard(); - // P1 Pawn - shogi.Move("C3", "C4", false); - // P2 Pawn - shogi.Move("G7", "G6", false); - // P1 Bishop capture P2 Bishop - shogi.Move("B2", "H8", false); - // P2 Pawn - shogi.Move("G6", "G5", false); - shogi.BoardState.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop); - shogi.BoardState["I9"].Should().NotBeNull(); - shogi.BoardState["I9"]!.WhichPiece.Should().Be(WhichPiece.Lance); - shogi.BoardState["I9"]!.Owner.Should().Be(WhichPlayer.Player2); - - // Act - P1 tries to place a piece where an opponent's piece resides. - var moveResult = shogi.Move(WhichPiece.Bishop, "I9"); - - // Assert - using var scope = new AssertionScope(); - moveResult.Should().NotBeNull(); - moveResult.IsSuccess.Should().BeFalse(); - shogi.BoardState.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop); - shogi.BoardState["I9"].Should().NotBeNull(); - shogi.BoardState["I9"]!.WhichPiece.Should().Be(WhichPiece.Lance); - shogi.BoardState["I9"]!.Owner.Should().Be(WhichPlayer.Player2); - } - - [Fact] - public void Check() - { - // Arrange - var shogi = MockShogiBoard(); - var board = shogi.BoardState; - // P1 Pawn - shogi.Move("C3", "C4", false); - // P2 Pawn - shogi.Move("G7", "G6", false); - - // Act - P1 Bishop, check - shogi.Move("B2", "G7", false); - - // Assert - board.InCheck.Should().Be(WhichPlayer.Player2); - } - - [Fact] - public void Promote() - { - // Arrange - var shogi = MockShogiBoard(); - var board = shogi.BoardState; - // P1 Pawn - shogi.Move("C3", "C4", false); - // P2 Pawn - shogi.Move("G7", "G6", false); - - // Act - P1 moves across promote threshold. - shogi.Move("B2", "G7", true); - - // Assert - using (new AssertionScope()) - { - board["B2"].Should().BeNull(); - board["G7"].Should().NotBeNull(); - board["G7"]!.WhichPiece.Should().Be(WhichPiece.Bishop); - board["G7"]!.Owner.Should().Be(WhichPlayer.Player1); - board["G7"]!.IsPromoted.Should().BeTrue(); - } - } - - [Fact] - public void Capture() - { - // Arrange - var shogi = MockShogiBoard(); - var board = shogi.BoardState; - var p1Bishop = board["B2"]; - p1Bishop!.WhichPiece.Should().Be(WhichPiece.Bishop); - shogi.Move("C3", "C4", false); - shogi.Move("G7", "G6", false); - - // Act - P1 Bishop captures P2 Bishop - shogi.Move("B2", "H8", false); - - // Assert - board["B2"].Should().BeNull(); - board["H8"].Should().Be(p1Bishop); - - board - .Player1Hand - .Should() - .ContainSingle(p => p.WhichPiece == WhichPiece.Bishop && p.Owner == WhichPlayer.Player1); - } - - [Fact] - public void CheckMate() - { - // Arrange - var shogi = MockShogiBoard(); - var board = shogi.BoardState; - // P1 Rook - shogi.Move("H2", "E2", false); - // P2 Gold - shogi.Move("F9", "G8", false); - // P1 Pawn - shogi.Move("E3", "E4", false); - // P2 other Gold - shogi.Move("D9", "C8", false); - // P1 same Pawn - shogi.Move("E4", "E5", false); - // P2 Pawn - shogi.Move("E7", "E6", false); - // P1 Pawn takes P2 Pawn - shogi.Move("E5", "E6", false); - // P2 King - shogi.Move("E9", "E8", false); - // P1 Pawn promotes; threatens P2 King - shogi.Move("E6", "E7", true); - // P2 King retreat - shogi.Move("E8", "E9", false); - - // Act - P1 Pawn wins by checkmate. - shogi.Move("E7", "E8", false); - - // Assert - checkmate - board.IsCheckmate.Should().BeTrue(); - board.InCheck.Should().Be(WhichPlayer.Player2); - } - - private static ShogiBoard MockShogiBoard() => new(BoardState.StandardStarting); + // Assert + Assert.Null(board["A3"]); + Assert.Equal(expectedPiece, board["A4"]); } + + [Fact] + public void AllowValidMoves_AfterCheck() + { + // Arrange + var shogi = MockShogiBoard(); + var board = shogi.BoardState; + // P1 Pawn + shogi.Move("C3", "C4", false); + // P2 Pawn + shogi.Move("G7", "G6", false); + // P1 Bishop puts P2 in check + shogi.Move("B2", "G7", false); + Assert.Equal(WhichPlayer.Player2, board.InCheck); + + // Act - P2 is able to un-check theirself. + /// P2 King moves out of check + shogi.Move("E9", "E8", false); + + // Assert + Assert.Null(board.InCheck); + } + + [Fact] + public void PreventInvalidMoves_MoveFromEmptyPosition() + { + // Arrange + var shogi = MockShogiBoard(); + var board = shogi.BoardState; + Assert.Null(board["D5"]); + + // Act + var moveResult = shogi.Move("D5", "D6", false); + + // Assert + Assert.NotNull(moveResult); + Assert.False(moveResult.IsSuccess); + Assert.Null(board["D5"]); + Assert.Null(board["D6"]); + Assert.Empty(board.Player1Hand); + Assert.Empty(board.Player2Hand); + } + + [Fact] + public void PreventInvalidMoves_MoveToCurrentPosition() + { + // Arrange + var shogi = MockShogiBoard(); + var board = shogi.BoardState; + var expectedPiece = board["A3"]; + + // Act - P1 "moves" pawn to the position it already exists at. + var moveResult = shogi.Move("A3", "A3", false); + + // Assert + Assert.NotNull(moveResult); + Assert.False(moveResult.IsSuccess); + Assert.Equal(expectedPiece, board["A3"]); + Assert.Empty(board.Player1Hand); + Assert.Empty(board.Player2Hand); + } + + [Fact] + public void PreventInvalidMoves_MoveSet() + { + // Arrange + var shogi = MockShogiBoard(); + var board = shogi.BoardState; + var expectedPiece = board["D1"]; + Assert.Equal(WhichPiece.GoldGeneral, expectedPiece!.WhichPiece); + + // Act - Move General illegally + var moveResult = shogi.Move("D1", "D5", false); + + // Assert + Assert.NotNull(moveResult); + Assert.False(moveResult.IsSuccess); + Assert.Equal(expectedPiece, board["D1"]); + Assert.Null(board["D5"]); + Assert.Empty(board.Player1Hand); + Assert.Empty(board.Player2Hand); + } + + [Fact] + public void PreventInvalidMoves_Ownership() + { + // Arrange + var shogi = MockShogiBoard(); + var board = shogi.BoardState; + var expectedPiece = board["A7"]; + Assert.Equal(WhichPlayer.Player2, expectedPiece!.Owner); + Assert.Equal(WhichPlayer.Player1, board.WhoseTurn); + + // Act - Move Player2 Pawn when it is Player1 turn. + var moveResult = shogi.Move("A7", "A6", false); + + // Assert + Assert.NotNull(moveResult); + Assert.False(moveResult.IsSuccess); + Assert.Equal(expectedPiece, board["A7"]); + Assert.Null(board["A6"]); + } + + [Fact] + public void PreventInvalidMoves_MoveThroughAllies() + { + // Arrange + var shogi = MockShogiBoard(); + var board = shogi.BoardState; + var lance = board["A1"]; + var pawn = board["A3"]; + Assert.Equal(lance!.Owner, pawn!.Owner); + + // Act - Move P1 Lance through P1 Pawn. + var moveResult = shogi.Move("A1", "A5", false); + + // Assert + Assert.NotNull(moveResult); + Assert.False(moveResult.IsSuccess); + Assert.Equal(lance, board["A1"]); + Assert.Equal(pawn, board["A3"]); + Assert.Null(board["A5"]); + } + + [Fact] + public void PreventInvalidMoves_CaptureAlly() + { + // Arrange + var shogi = MockShogiBoard(); + var board = shogi.BoardState; + var knight = board["B1"]; + var pawn = board["C3"]; + Assert.Equal(knight!.Owner, pawn!.Owner); + + // Act - P1 Knight tries to capture P1 Pawn. + var moveResult = shogi.Move("B1", "C3", false); + + // Assert + Assert.NotNull(moveResult); + Assert.False(moveResult.IsSuccess); + Assert.Equal(knight, board["B1"]); + Assert.Equal(pawn, board["C3"]); + Assert.Empty(board.Player1Hand); + Assert.Empty(board.Player2Hand); + } + + [Fact] + public void PreventInvalidMoves_Check() + { + // Arrange + var shogi = MockShogiBoard(); + var board = shogi.BoardState; + // P1 Pawn + shogi.Move("C3", "C4", false); + // P2 Pawn + shogi.Move("G7", "G6", false); + // P1 Bishop puts P2 in check + shogi.Move("B2", "G7", false); + Assert.Equal(WhichPlayer.Player2, board.InCheck); + var lance = board["I9"]; + + // Act - P2 moves Lance while in check. + var moveResult = shogi.Move("I9", "I8", false); + + // Assert + Assert.NotNull(moveResult); + Assert.False(moveResult.IsSuccess); + Assert.Equal(WhichPlayer.Player2, board.InCheck); + Assert.Equal(lance, board["I9"]); + Assert.Null(board["I8"]); + } + + [Fact] + public void PreventInvalidDrops_MoveSet() + { + // Arrange + var shogi = MockShogiBoard(); + var board = shogi.BoardState; + // P1 Pawn + shogi.Move("C3", "C4", false); + // P2 Pawn + shogi.Move("I7", "I6", false); + // P1 Bishop takes P2 Pawn. + shogi.Move("B2", "G7", false); + // P2 Gold, block check from P1 Bishop. + shogi.Move("F9", "F8", false); + // P1 Bishop takes P2 Bishop, promotes so it can capture P2 Knight and P2 Lance + shogi.Move("G7", "H8", true); + // P2 Pawn again + shogi.Move("I6", "I5", false); + // P1 Bishop takes P2 Knight + shogi.Move("H8", "H9", false); + // P2 Pawn again + shogi.Move("I5", "I4", false); + // P1 Bishop takes P2 Lance + shogi.Move("H9", "I9", false); + // P2 Pawn captures P1 Pawn + shogi.Move("I4", "I3", false); + Assert.Equal(4, board.Player1Hand.Count); + Assert.Single(board.Player1Hand, p => p.WhichPiece == WhichPiece.Knight); + Assert.Single(board.Player1Hand, p => p.WhichPiece == WhichPiece.Lance); + Assert.Single(board.Player1Hand, p => p.WhichPiece == WhichPiece.Pawn); + Assert.Single(board.Player1Hand, p => p.WhichPiece == WhichPiece.Bishop); + Assert.Equal(WhichPlayer.Player1, board.WhoseTurn); + + // Act | Assert - Illegally placing Knight from the hand in farthest rank. + Assert.Null(board["H9"]); + var moveResult = shogi.Move(WhichPiece.Knight, "H9"); + Assert.NotNull(moveResult); + Assert.False(moveResult.IsSuccess); + Assert.Null(board["H9"]); + Assert.Single(board.Player1Hand, p => p.WhichPiece == WhichPiece.Knight); + + // Act | Assert - Illegally placing Knight from the hand in second farthest row. + Assert.Null(board["H8"]); + moveResult = shogi.Move(WhichPiece.Knight, "H8"); + Assert.NotNull(moveResult); + Assert.False(moveResult.IsSuccess); + Assert.Null(board["H8"]); + Assert.Single(board.Player1Hand, p => p.WhichPiece == WhichPiece.Knight); + + // Act | Assert - Illegally place Lance from the hand. + Assert.Null(board["H9"]); + moveResult = shogi.Move(WhichPiece.Knight, "H9"); + Assert.NotNull(moveResult); + Assert.False(moveResult.IsSuccess); + Assert.Null(board["H9"]); + Assert.Single(board.Player1Hand, p => p.WhichPiece == WhichPiece.Lance); + + // Act | Assert - Illegally place Pawn from the hand. + Assert.Null(board["H9"]); + moveResult = shogi.Move(WhichPiece.Pawn, "H9"); + Assert.NotNull(moveResult); + Assert.False(moveResult.IsSuccess); + Assert.Null(board["H9"]); + Assert.Single(board.Player1Hand, p => p.WhichPiece == WhichPiece.Pawn); + + // // Act | Assert - Illegally place Pawn from the hand in a row which already has an unpromoted Pawn. + // // TODO + } + + [Fact] + public void PreventInvalidDrop_Check() + { + // Arrange + var shogi = MockShogiBoard(); + // P1 Pawn + Assert.True(shogi.Move("C3", "C4", false).IsSuccess); + // P2 Pawn + Assert.True(shogi.Move("G7", "G6", false).IsSuccess); + // P1 Pawn, arbitrary move. + Assert.True(shogi.Move("A3", "A4", false).IsSuccess); + // P2 Bishop takes P1 Bishop + Assert.True(shogi.Move("H8", "B2", false).IsSuccess); + // P1 Silver takes P2 Bishop + Assert.True(shogi.Move("C1", "B2", false).IsSuccess); + // P2 Pawn, arbtrary move + Assert.True(shogi.Move("A7", "A6", false).IsSuccess); + // P1 drop Bishop, place P2 in check + Assert.True(shogi.Move(WhichPiece.Bishop, "G7").IsSuccess); + Assert.Equal(WhichPlayer.Player2, shogi.BoardState.InCheck); + Assert.Single(shogi.BoardState.Player2Hand, p => p.WhichPiece == WhichPiece.Bishop); + Assert.Null(shogi.BoardState["E5"]); + + // Act - P2 places a Bishop while in check. + var moveResult = shogi.Move(WhichPiece.Bishop, "E5"); + + // Assert + Assert.NotNull(moveResult); + Assert.False(moveResult.IsSuccess); + Assert.Null(shogi.BoardState["E5"]); + Assert.Equal(WhichPlayer.Player2, shogi.BoardState.InCheck); + Assert.Single(shogi.BoardState.Player2Hand, p => p.WhichPiece == WhichPiece.Bishop); + } + + [Fact] + public void PreventInvalidDrop_Capture() + { + // Arrange + var shogi = MockShogiBoard(); + // P1 Pawn + shogi.Move("C3", "C4", false); + // P2 Pawn + shogi.Move("G7", "G6", false); + // P1 Bishop capture P2 Bishop + shogi.Move("B2", "H8", false); + // P2 Pawn + shogi.Move("G6", "G5", false); + Assert.Single(shogi.BoardState.Player1Hand, p => p.WhichPiece == WhichPiece.Bishop); + Assert.NotNull(shogi.BoardState["I9"]); + Assert.Equal(WhichPiece.Lance, shogi.BoardState["I9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, shogi.BoardState["I9"]!.Owner); + + // Act - P1 tries to place a piece where an opponent's piece resides. + var moveResult = shogi.Move(WhichPiece.Bishop, "I9"); + + // Assert + Assert.NotNull(moveResult); + Assert.False(moveResult.IsSuccess); + Assert.Single(shogi.BoardState.Player1Hand, p => p.WhichPiece == WhichPiece.Bishop); + Assert.NotNull(shogi.BoardState["I9"]); + Assert.Equal(WhichPiece.Lance, shogi.BoardState["I9"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player2, shogi.BoardState["I9"]!.Owner); + } + + [Fact] + public void Check() + { + // Arrange + var shogi = MockShogiBoard(); + var board = shogi.BoardState; + // P1 Pawn + shogi.Move("C3", "C4", false); + // P2 Pawn + shogi.Move("G7", "G6", false); + + // Act - P1 Bishop, check + shogi.Move("B2", "G7", false); + + // Assert + Assert.Equal(WhichPlayer.Player2, board.InCheck); + } + + [Fact] + public void Promote() + { + // Arrange + var shogi = MockShogiBoard(); + var board = shogi.BoardState; + // P1 Pawn + shogi.Move("C3", "C4", false); + // P2 Pawn + shogi.Move("G7", "G6", false); + + // Act - P1 moves across promote threshold. + shogi.Move("B2", "G7", true); + + // Assert + Assert.Null(board["B2"]); + Assert.NotNull(board["G7"]); + Assert.Equal(WhichPiece.Bishop, board["G7"]!.WhichPiece); + Assert.Equal(WhichPlayer.Player1, board["G7"]!.Owner); + Assert.True(board["G7"]!.IsPromoted); + } + + [Fact] + public void Capture() + { + // Arrange + var shogi = MockShogiBoard(); + var board = shogi.BoardState; + var p1Bishop = board["B2"]; + Assert.Equal(WhichPiece.Bishop, p1Bishop!.WhichPiece); + shogi.Move("C3", "C4", false); + shogi.Move("G7", "G6", false); + + // Act - P1 Bishop captures P2 Bishop + shogi.Move("B2", "H8", false); + + // Assert + Assert.Null(board["B2"]); + Assert.Equal(p1Bishop, board["H8"]); + Assert.Single(board.Player1Hand, p => p.WhichPiece == WhichPiece.Bishop && p.Owner == WhichPlayer.Player1); + } + + [Fact] + public void CheckMate() + { + // Arrange + var shogi = MockShogiBoard(); + var board = shogi.BoardState; + // P1 Rook + shogi.Move("H2", "E2", false); + // P2 Gold + shogi.Move("F9", "G8", false); + // P1 Pawn + shogi.Move("E3", "E4", false); + // P2 other Gold + shogi.Move("D9", "C8", false); + // P1 same Pawn + shogi.Move("E4", "E5", false); + // P2 Pawn + shogi.Move("E7", "E6", false); + // P1 Pawn takes P2 Pawn + shogi.Move("E5", "E6", false); + // P2 King + shogi.Move("E9", "E8", false); + // P1 Pawn promotes; threatens P2 King + shogi.Move("E6", "E7", true); + // P2 King retreat + shogi.Move("E8", "E9", false); + + // Act - P1 Pawn wins by checkmate. + shogi.Move("E7", "E8", false); + + // Assert - checkmate + Assert.True(board.IsCheckmate); + Assert.Equal(WhichPlayer.Player2, board.InCheck); + } + + private static ShogiBoard MockShogiBoard() => new(BoardState.StandardStarting); } diff --git a/Tests/UnitTests/UnitTests.csproj b/Tests/UnitTests/UnitTests.csproj index 69bf354..121b2e1 100644 --- a/Tests/UnitTests/UnitTests.csproj +++ b/Tests/UnitTests/UnitTests.csproj @@ -1,36 +1,31 @@  - net8.0 + net10.0 enable false - - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - + - - + diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 8764968..dfb93ab 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -10,11 +10,15 @@ pool: vmImage: 'windows-latest' variables: - solution: '**/*.sln' - buildPlatform: 'Any CPU' - buildConfiguration: 'Release' - apiProjectName: 'Shogi.Api' - uiProjectName: 'Shogi.UI' +solution: '**/*.sln' +buildPlatform: 'Any CPU' +buildConfiguration: 'Release' +projectName: 'Shogi' +# Database variables - Set these as pipeline variables in Azure DevOps +# DatabaseServer: 'your-sql-server-address' +# DatabaseName: 'ShogiDb' +# DatabaseUser: 'your-db-user' +# DatabasePassword: 'your-db-password' # Mark as secret in Azure DevOps steps: @@ -23,13 +27,8 @@ steps: - task: UseDotNet@2 inputs: packageType: sdk - #useGlobalJson: true - version: 8.x - installationPath: $(Agent.ToolsDirectory)/dotnet #Install if not already present. - -- task: CmdLine@2 - inputs: - script: 'dotnet workload install wasm-tools' + version: 10.x + installationPath: $(Agent.ToolsDirectory)/dotnet - task: NuGetCommand@2 inputs: @@ -37,13 +36,7 @@ steps: - task: FileTransform@1 inputs: - folderPath: '$(System.DefaultWorkingDirectory)\$(uiProjectName)' - fileType: 'json' - targetFiles: 'wwwroot/appsettings.json' - -- task: FileTransform@1 - inputs: - folderPath: '$(System.DefaultWorkingDirectory)\$(apiProjectName)' + folderPath: '$(System.DefaultWorkingDirectory)\$(projectName)' fileType: 'json' targetFiles: 'appsettings.json' @@ -51,25 +44,48 @@ steps: inputs: command: 'publish' publishWebProjects: false - arguments: '-c Release' + projects: '$(projectName)/$(projectName).csproj' + arguments: '-c Release -o $(Build.ArtifactStagingDirectory)' zipAfterPublish: false +- task: PowerShell@2 + displayName: "Generate EF Core migration script" + inputs: + targetType: 'inline' + script: | + dotnet tool restore --tool-manifest $(projectName)/.config/dotnet-tools.json + dotnet ef migrations script --idempotent --project $(projectName)/$(projectName).csproj --output $(Build.ArtifactStagingDirectory)/migrations.sql + workingDirectory: '$(System.DefaultWorkingDirectory)' + - task: CopyFilesOverSSH@0 - displayName: "Copy API files." + displayName: "Copy database migration script" inputs: sshEndpoint: 'LucaServer' - sourceFolder: '$(System.DefaultWorkingDirectory)/$(apiProjectName)/bin/Release/net8.0/publish' - contents: '**' - targetFolder: '/var/www/apps/$(apiProjectName)' + sourceFolder: '$(Build.ArtifactStagingDirectory)' + contents: 'migrations.sql' + targetFolder: '/var/www/apps/$(projectName)/migrations' + readyTimeout: '20000' + +- task: SSH@0 + displayName: "Apply database migrations" + inputs: + sshEndpoint: 'LucaServer' + runOptions: 'commands' + commands: | + # Install sqlcmd if not already installed (one-time setup) + # which sqlcmd || curl https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/trusted.gpg.d/microsoft.asc && curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list && sudo apt-get update && sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18 unixodbc-dev + + # Apply migrations (adjust connection string as needed) + /opt/mssql-tools18/bin/sqlcmd -S $(DatabaseServer) -d $(DatabaseName) -U $(DatabaseUser) -P $(DatabasePassword) -i /var/www/apps/$(projectName)/migrations/migrations.sql -C readyTimeout: '20000' - task: CopyFilesOverSSH@0 - displayName: "Copy UI files." + displayName: "Copy application files" inputs: sshEndpoint: 'LucaServer' - sourceFolder: '$(System.DefaultWorkingDirectory)/$(uiProjectName)/bin/Release/net8.0/publish' + sourceFolder: '$(Build.ArtifactStagingDirectory)' contents: '**' - targetFolder: '/var/www/apps/$(uiProjectName)' + targetFolder: '/var/www/apps/$(projectName)' readyTimeout: '20000' - task: SSH@0 @@ -77,5 +93,5 @@ steps: inputs: sshEndpoint: 'LucaServer' runOptions: 'commands' - commands: 'sudo systemctl restart kestrel-shogi.api.service' + commands: 'sudo systemctl restart kestrel-Shogi.service' readyTimeout: '20000' \ No newline at end of file diff --git a/global.json b/global.json index 9e0754e..f72210c 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.0", + "version": "10.0.100", "rollForward": "latestFeature" } } \ No newline at end of file