diff --git a/.gitignore b/.gitignore index 26787d3..70b8a69 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ Thumbs.db #Luke bin obj +*.user diff --git a/Benchmarking/Benchmarking.csproj b/Benchmarking/Benchmarking.csproj new file mode 100644 index 0000000..a2ca11e --- /dev/null +++ b/Benchmarking/Benchmarking.csproj @@ -0,0 +1,13 @@ + + + + net6.0 + true + Exe + + + + + + + diff --git a/Benchmarking/Benchmarks.cs b/Benchmarking/Benchmarks.cs new file mode 100644 index 0000000..4270a3e --- /dev/null +++ b/Benchmarking/Benchmarks.cs @@ -0,0 +1,106 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Running; +using System; +using System.Linq; +using System.Numerics; + +namespace Benchmarking +{ + public class Benchmarks + { + private readonly Vector2[] directions; + // Consumer is for IEnumerables. + private readonly Consumer consumer = new(); + + public Benchmarks() + { + //moves = new[] + //{ + // // P1 Rook + // new Move { From = new Vector2(7, 1), To = new Vector2(4, 1) }, + // // P2 Gold + // new Move { From = new Vector2(3, 8), To = new Vector2(2, 7) }, + // // P1 Pawn + // new Move { From = new Vector2(4, 2), To = new Vector2(4, 3) }, + // // P2 other Gold + // new Move { From = new Vector2(5, 8), To = new Vector2(6, 7) }, + // // P1 same Pawn + // new Move { From = new Vector2(4, 3), To = new Vector2(4, 4) }, + // // P2 Pawn + // new Move { From = new Vector2(4, 6), To = new Vector2(4, 5) }, + // // P1 Pawn takes P2 Pawn + // new Move { From = new Vector2(4, 4), To = new Vector2(4, 5) }, + // // P2 King + // new Move { From = new Vector2(4, 8), To = new Vector2(4, 7) }, + // // P1 Pawn promotes + // new Move { From = new Vector2(4, 5), To = new Vector2(4, 6), IsPromotion = true }, + // // P2 King retreat + // new Move { From = new Vector2(4, 7), To = new Vector2(4, 8) }, + //}; + //var rand = new Random(); + + //directions = new Vector2[10]; + //for (var n = 0; n < 10; n++) directions[n] = new Vector2(rand.Next(-2, 2), rand.Next(-2, 2)); + } + + [Benchmark] + public void One() + { + for(var i=0; i<10000; i++) + { + Guid.NewGuid(); + } + } + + //[Benchmark] + public void Two() + { + } + + + + public Vector2 FindDirection(Vector2[] directions, Vector2 destination) + { + var smallerDistance = float.MaxValue; + Vector2 found = Vector2.Zero; + foreach (var d in directions) + { + var distance = Vector2.Distance(d, destination); + if (distance < smallerDistance) + { + smallerDistance = distance; + found = d; + } + } + return found; + } + + + public Vector2 FindDirectionLinq(Vector2[] directions, Vector2 destination) => + directions.Aggregate((a, b) => Vector2.Distance(destination, a) < Vector2.Distance(destination, b) ? a : b); + + + + + [Benchmark] + public void Directions_A() + { + FindDirection(directions, new Vector2(8, 7)); + } + [Benchmark] + public void Directions_B() + { + FindDirectionLinq(directions, new Vector2(8, 7)); + } + } + + public class Program + { + public static void Main(string[] args) + { + BenchmarkRunner.Run(); + Console.WriteLine("Done"); + } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/GetGuestToken.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/GetGuestToken.cs new file mode 100644 index 0000000..90fc455 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/GetGuestToken.cs @@ -0,0 +1,18 @@ +using System; + +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api +{ + public class GetGuestTokenResponse + { + public string UserId { get; } + public string DisplayName { get; } + public Guid OneTimeToken { get; } + + public GetGuestTokenResponse(string id, string displayName, Guid token) + { + UserId = id; + DisplayName = displayName; + OneTimeToken = token; + } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/GetSession.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/GetSession.cs new file mode 100644 index 0000000..8fdbfe4 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/GetSession.cs @@ -0,0 +1,16 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; +using System.Collections.Generic; + +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api +{ + public class GetSessionResponse + { + public Game Game { get; set; } + /// + /// The perspective on the game of the requesting user. + /// + public WhichPerspective PlayerPerspective { get; set; } + public BoardState BoardState { get; set; } + public IList MoveHistory { get; set; } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/GetSessions.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/GetSessions.cs new file mode 100644 index 0000000..cc972bc --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/GetSessions.cs @@ -0,0 +1,11 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; +using System.Collections.ObjectModel; + +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api +{ + public class GetSessionsResponse + { + public Collection PlayerHasJoinedSessions { get; set; } + public Collection AllOtherSessions { get; set; } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/GetToken.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/GetToken.cs new file mode 100644 index 0000000..acc03a9 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/GetToken.cs @@ -0,0 +1,14 @@ +using System; + +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api +{ + public class GetTokenResponse + { + public Guid OneTimeToken { get; } + + public GetTokenResponse(Guid token) + { + OneTimeToken = token; + } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/Messages/GetGuestToken.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/Messages/GetGuestToken.cs deleted file mode 100644 index 6f6a751..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/Messages/GetGuestToken.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; - -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api.Messages -{ - public class GetGuestToken - { - public string ClientId { get; set; } - } - - public class GetGuestTokenResponse - { - public string ClientId { get; } - public Guid OneTimeToken { get; } - - public GetGuestTokenResponse(string clientId, Guid token) - { - ClientId = clientId; - OneTimeToken = token; - } - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/Messages/GetToken.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/Messages/GetToken.cs deleted file mode 100644 index e2f3c98..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/Messages/GetToken.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api.Messages -{ - public class GetTokenResponse - { - public Guid OneTimeToken { get; } - - public GetTokenResponse(Guid token) - { - OneTimeToken = token; - } - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/Messages/PostGameInvitation.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostGameInvitation.cs similarity index 85% rename from Gameboard.ShogiUI.Sockets.ServiceModels/Api/Messages/PostGameInvitation.cs rename to Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostGameInvitation.cs index 10850f6..acb270f 100644 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/Messages/PostGameInvitation.cs +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostGameInvitation.cs @@ -1,4 +1,4 @@ -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api.Messages +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api { public class PostGameInvitation { diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostMove.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostMove.cs new file mode 100644 index 0000000..012c6ea --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostMove.cs @@ -0,0 +1,11 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; +using System.ComponentModel.DataAnnotations; + +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api +{ + public class PostMove + { + [Required] + public Move Move { get; set; } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostSession.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostSession.cs new file mode 100644 index 0000000..ebe9665 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostSession.cs @@ -0,0 +1,8 @@ +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api +{ + public class PostSession + { + public string Name { get; set; } + public bool IsPrivate { get; set; } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Gameboard.ShogiUI.Sockets.ServiceModels.csproj b/Gameboard.ShogiUI.Sockets.ServiceModels/Gameboard.ShogiUI.Sockets.ServiceModels.csproj index f208d30..ec1c55c 100644 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Gameboard.ShogiUI.Sockets.ServiceModels.csproj +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Gameboard.ShogiUI.Sockets.ServiceModels.csproj @@ -1,7 +1,10 @@ - net5.0 + net6.0 + true + 5 + enable diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/CreateGame.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/CreateGame.cs new file mode 100644 index 0000000..e1b59bb --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/CreateGame.cs @@ -0,0 +1,21 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; +using System; + +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket +{ + public class CreateGameResponse : IResponse + { + public string Action { get; } + public Game Game { get; set; } + + /// + /// The player who created the game. + /// + public string PlayerName { get; set; } + + public CreateGameResponse() + { + Action = ClientAction.CreateGame.ToString(); + } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/IRequest.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/IRequest.cs new file mode 100644 index 0000000..aca4cc7 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/IRequest.cs @@ -0,0 +1,9 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; + +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket +{ + public interface IRequest + { + ClientAction Action { get; } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/IResponse.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/IResponse.cs new file mode 100644 index 0000000..69d18c6 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/IResponse.cs @@ -0,0 +1,7 @@ +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket +{ + public interface IResponse + { + string Action { get; } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Interfaces/IRequest.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Interfaces/IRequest.cs deleted file mode 100644 index ce8c0a4..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Interfaces/IRequest.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; - -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Interfaces -{ - public interface IRequest - { - ClientAction Action { get; set; } - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Interfaces/IResponse.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Interfaces/IResponse.cs deleted file mode 100644 index 8c1bed8..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Interfaces/IResponse.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Interfaces -{ - public interface IResponse - { - string Action { get; } - string Error { get; set; } - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/JoinGame.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/JoinGame.cs new file mode 100644 index 0000000..22fac9c --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/JoinGame.cs @@ -0,0 +1,41 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; + +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket +{ + public class JoinByCodeRequest : IRequest + { + public ClientAction Action { get; set; } + public string JoinCode { get; set; } = ""; + } + + public class JoinGameRequest : IRequest + { + public ClientAction Action { get; set; } + public string GameName { get; set; } = ""; + } + + public class JoinGameResponse : IResponse + { + public string Action { get; protected set; } + public string GameName { get; set; } + /// + /// The player who joined the game. + /// + public string PlayerName { get; set; } + + public JoinGameResponse() + { + Action = ClientAction.JoinGame.ToString(); + GameName = ""; + PlayerName = ""; + } + } + + public class JoinByCodeResponse : JoinGameResponse, IResponse + { + public JoinByCodeResponse() + { + Action = ClientAction.JoinByCode.ToString(); + } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/CreateGame.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/CreateGame.cs deleted file mode 100644 index 09d7455..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/CreateGame.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Interfaces; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; - -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages -{ - public class CreateGameRequest : IRequest - { - public ClientAction Action { get; set; } - public string GameName { get; set; } - public bool IsPrivate { get; set; } - } - - public class CreateGameResponse : IResponse - { - public string Action { get; private set; } - public string Error { get; set; } - public Game Game { get; set; } - public string PlayerName { get; set; } - - public CreateGameResponse(ClientAction action) - { - Action = action.ToString(); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/ErrorResponse.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/ErrorResponse.cs deleted file mode 100644 index 376ffc8..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/ErrorResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Interfaces; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; - -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages -{ - public class ErrorResponse : IResponse - { - public string Action { get; private set; } - public string Error { get; set; } - - public ErrorResponse(ClientAction action) - { - Action = action.ToString(); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/JoinByCode.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/JoinByCode.cs deleted file mode 100644 index 51f2f8d..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/JoinByCode.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Interfaces; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; - -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages -{ - public class JoinByCode : IRequest - { - public ClientAction Action { get; set; } - public string JoinCode { get; set; } - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/JoinGame.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/JoinGame.cs deleted file mode 100644 index ef8842d..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/JoinGame.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Interfaces; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; - -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages -{ - public class JoinGameRequest : IRequest - { - public ClientAction Action { get; set; } - public string GameName { get; set; } - } - - public class JoinGameResponse : IResponse - { - public string Action { get; private set; } - public string Error { get; set; } - public string GameName { get; set; } - public string PlayerName { get; set; } - - public JoinGameResponse(ClientAction action) - { - Action = action.ToString(); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/ListGames.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/ListGames.cs deleted file mode 100644 index 1f6541f..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/ListGames.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Interfaces; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; -using System.Collections.Generic; - -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages -{ - public class ListGamesRequest : IRequest - { - public ClientAction Action { get; set; } - } - - public class ListGamesResponse : IResponse - { - public string Action { get; private set; } - public string Error { get; set; } - public IEnumerable Games { get; set; } - - public ListGamesResponse(ClientAction action) - { - Action = action.ToString(); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/LoadGame.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/LoadGame.cs deleted file mode 100644 index 19e6c08..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/LoadGame.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Interfaces; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; -using System.Collections.Generic; - -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages -{ - public class LoadGameRequest : IRequest - { - public ClientAction Action { get; set; } - public string GameName { get; set; } - } - - public class LoadGameResponse : IResponse - { - public string Action { get; private set; } - public Game Game { get; set; } - public IEnumerable Moves { get; set; } - public string Error { get; set; } - - public LoadGameResponse(ClientAction action) - { - Action = action.ToString(); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/Move.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/Move.cs deleted file mode 100644 index 5039196..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Messages/Move.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Interfaces; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; - -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages -{ - public class MoveRequest : IRequest - { - public ClientAction Action { get; set; } - public string GameName { get; set; } - public Move Move { get; set; } - } - - public class MoveResponse : IResponse - { - public string Action { get; } - public string Error { get; set; } - public string GameName { get; set; } - public Move Move { get; set; } - public string PlayerName { get; set; } - - public MoveResponse(ClientAction action) - { - Action = action.ToString(); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Move.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Move.cs new file mode 100644 index 0000000..0b19a44 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Move.cs @@ -0,0 +1,19 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; + +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket +{ + public class MoveResponse : IResponse + { + public string Action { get; } + public string GameName { get; set; } + /// + /// The player that made the move. + /// + public string PlayerName { get; set; } + + public MoveResponse() + { + Action = ClientAction.Move.ToString(); + } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Types/ClientActionEnum.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Types/ClientActionEnum.cs deleted file mode 100644 index 9e0952e..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Types/ClientActionEnum.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types -{ - public enum ClientAction - { - ListGames, - CreateGame, - JoinGame, - JoinByCode, - LoadGame, - Move, - KeepAlive - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Types/Coords.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Types/Coords.cs deleted file mode 100644 index d64dae4..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Types/Coords.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types -{ - public class Coords - { - public int X { get; set; } - public int Y { get; set; } - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Types/Game.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Types/Game.cs deleted file mode 100644 index a4a2ebe..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Types/Game.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types -{ - public class Game - { - public string GameName { get; set; } - public string[] Players { get; set; } - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Types/Move.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Types/Move.cs deleted file mode 100644 index f815b00..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Types/Move.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types -{ - public class Move - { - public string PieceFromCaptured { get; set; } - public Coords From { get; set; } - public Coords To { get; set; } - public bool IsPromotion { get; set; } - - /// - /// Toggles perspective of this move. (ie from player 1 to player 2) - /// - public static Move ConvertPerspective(Move m) - { - var convertedMove = new Move - { - To = new Coords - { - X = 8 - m.To.X, - Y = 8 - m.To.Y - }, - From = new Coords - { - X = 8 - m.From.X, - Y = 8 - m.From.Y - }, - IsPromotion = m.IsPromotion, - PieceFromCaptured = m.PieceFromCaptured - }; - return convertedMove; - } - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Types/BoardState.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/BoardState.cs new file mode 100644 index 0000000..b6d0a98 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/BoardState.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; + +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types +{ + public class BoardState + { + public Dictionary Board { get; set; } = new Dictionary(); + public IReadOnlyCollection Player1Hand { get; set; } = Array.Empty(); + public IReadOnlyCollection Player2Hand { get; set; } = Array.Empty(); + public WhichPerspective? PlayerInCheck { get; set; } + public WhichPerspective WhoseTurn { get; set; } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Types/ClientActionEnum.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/ClientActionEnum.cs new file mode 100644 index 0000000..99b9446 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/ClientActionEnum.cs @@ -0,0 +1,10 @@ +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types +{ + public enum ClientAction + { + CreateGame, + JoinGame, + JoinByCode, + Move + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Types/Game.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/Game.cs new file mode 100644 index 0000000..5b70ad8 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/Game.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types +{ + public class Game + { + public string Player1 { get; set; } + public string? Player2 { get; set; } + public string GameName { get; set; } = string.Empty; + + /// + /// Players[0] is the session owner, Players[1] is the other person. + /// + public IReadOnlyList Players + { + get + { + var list = new List(2) { Player1 }; + if (!string.IsNullOrEmpty(Player2)) list.Add(Player2); + return list; + } + } + + /// + /// Constructor for serialization. + /// + public Game() + { + } + + public Game(string gameName, string player1, string? player2 = null) + { + GameName = gameName; + Player1 = player1; + Player2 = player2; + } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Types/Move.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/Move.cs new file mode 100644 index 0000000..7116648 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/Move.cs @@ -0,0 +1,12 @@ +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types +{ + public class Move + { + public WhichPiece? PieceFromCaptured { get; set; } + /// Board position notation, like A3 or G1 + public string? From { get; set; } + /// Board position notation, like A3 or G1 + public string To { get; set; } = string.Empty; + public bool IsPromotion { get; set; } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Types/Piece.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/Piece.cs new file mode 100644 index 0000000..8e28d04 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/Piece.cs @@ -0,0 +1,9 @@ +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types +{ + public class Piece + { + public bool IsPromoted { get; set; } + public WhichPiece WhichPiece { get; set; } + public WhichPerspective Owner { get; set; } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Types/User.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/User.cs new file mode 100644 index 0000000..8e9a72a --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/User.cs @@ -0,0 +1,9 @@ +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types +{ + public class User + { + public string Id { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Types/WhichPerspective.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/WhichPerspective.cs new file mode 100644 index 0000000..cf8a4c4 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/WhichPerspective.cs @@ -0,0 +1,9 @@ +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types +{ + public enum WhichPerspective + { + Player1, + Player2, + Spectator + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Types/WhichPiece.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/WhichPiece.cs new file mode 100644 index 0000000..214bdba --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/WhichPiece.cs @@ -0,0 +1,14 @@ +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types +{ + public enum WhichPiece + { + King, + GoldGeneral, + SilverGeneral, + Bishop, + Rook, + Knight, + Lance, + Pawn + } +} diff --git a/Gameboard.ShogiUI.Sockets.sln b/Gameboard.ShogiUI.Sockets.sln index 85cdd75..3b54d4e 100644 --- a/Gameboard.ShogiUI.Sockets.sln +++ b/Gameboard.ShogiUI.Sockets.sln @@ -1,12 +1,26 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30503.244 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Gameboard.ShogiUI.Sockets", "Gameboard.ShogiUI.Sockets\Gameboard.ShogiUI.Sockets.csproj", "{4FF35F9D-E525-46CF-A8A6-A147FE50AD68}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Gameboard.ShogiUI.Sockets.ServiceModels", "Gameboard.ShogiUI.Sockets.ServiceModels\Gameboard.ShogiUI.Sockets.ServiceModels.csproj", "{FE775DE4-50F0-4C5D-AD2B-01320B1E7086}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F35A56FB-B8D8-4CB7-ABF6-D40049C9B42E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Gameboard.ShogiUI.UnitTests", "Gameboard.ShogiUI.UnitTests\Gameboard.ShogiUI.UnitTests.csproj", "{DC8A933A-DBCB-46B9-AA0B-7B3DC9E763F3}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Benchmarking", "Benchmarking\Benchmarking.csproj", "{DADFF5D6-581F-4D69-845D-53ABD6ABF62F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PathFinding", "PathFinding\PathFinding.csproj", "{A0AC8C5A-6ADA-45C6-BD1E-EB1061213E47}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Gameboard.ShogiUI.xUnitTests", "Gameboard.ShogiUI.xUnitTests\Gameboard.ShogiUI.xUnitTests.csproj", "{12530716-C11E-40CE-9F71-CCCC243F03E1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shogi.Domain", "Shogi.Domain\Shogi.Domain.csproj", "{0211B1E4-20F0-4058-AAC4-3845D19910AF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shogi.Domain.UnitTests", "Shogi.Domain.UnitTests\Shogi.Domain.UnitTests.csproj", "{F256989E-B6AF-4731-9DB4-88991C40B2CE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,10 +35,39 @@ Global {FE775DE4-50F0-4C5D-AD2B-01320B1E7086}.Debug|Any CPU.Build.0 = Debug|Any CPU {FE775DE4-50F0-4C5D-AD2B-01320B1E7086}.Release|Any CPU.ActiveCfg = Release|Any CPU {FE775DE4-50F0-4C5D-AD2B-01320B1E7086}.Release|Any CPU.Build.0 = Release|Any CPU + {DC8A933A-DBCB-46B9-AA0B-7B3DC9E763F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DC8A933A-DBCB-46B9-AA0B-7B3DC9E763F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DC8A933A-DBCB-46B9-AA0B-7B3DC9E763F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DC8A933A-DBCB-46B9-AA0B-7B3DC9E763F3}.Release|Any CPU.Build.0 = Release|Any CPU + {DADFF5D6-581F-4D69-845D-53ABD6ABF62F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DADFF5D6-581F-4D69-845D-53ABD6ABF62F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DADFF5D6-581F-4D69-845D-53ABD6ABF62F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DADFF5D6-581F-4D69-845D-53ABD6ABF62F}.Release|Any CPU.Build.0 = Release|Any CPU + {A0AC8C5A-6ADA-45C6-BD1E-EB1061213E47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A0AC8C5A-6ADA-45C6-BD1E-EB1061213E47}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A0AC8C5A-6ADA-45C6-BD1E-EB1061213E47}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A0AC8C5A-6ADA-45C6-BD1E-EB1061213E47}.Release|Any CPU.Build.0 = Release|Any CPU + {12530716-C11E-40CE-9F71-CCCC243F03E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {12530716-C11E-40CE-9F71-CCCC243F03E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {12530716-C11E-40CE-9F71-CCCC243F03E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {12530716-C11E-40CE-9F71-CCCC243F03E1}.Release|Any CPU.Build.0 = Release|Any CPU + {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 + {F256989E-B6AF-4731-9DB4-88991C40B2CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F256989E-B6AF-4731-9DB4-88991C40B2CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F256989E-B6AF-4731-9DB4-88991C40B2CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F256989E-B6AF-4731-9DB4-88991C40B2CE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {DC8A933A-DBCB-46B9-AA0B-7B3DC9E763F3} = {F35A56FB-B8D8-4CB7-ABF6-D40049C9B42E} + {12530716-C11E-40CE-9F71-CCCC243F03E1} = {F35A56FB-B8D8-4CB7-ABF6-D40049C9B42E} + {F256989E-B6AF-4731-9DB4-88991C40B2CE} = {F35A56FB-B8D8-4CB7-ABF6-D40049C9B42E} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {1D0B04F2-0DA1-4CB4-A82A-5A1C3B52ACEB} EndGlobalSection diff --git a/Gameboard.ShogiUI.Sockets/AnonymousSessionMiddleware.cs b/Gameboard.ShogiUI.Sockets/AnonymousSessionMiddleware.cs new file mode 100644 index 0000000..f39e649 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/AnonymousSessionMiddleware.cs @@ -0,0 +1,39 @@ +namespace Gameboard.ShogiUI.Sockets +{ + namespace anonymous_session.Middlewares + { + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Authentication; + using System.Security.Claims; + + /// + /// TODO: Use this example in the guest session logic instead of custom claims. + /// + public class AnonymousSessionMiddleware + { + private readonly RequestDelegate _next; + + public AnonymousSessionMiddleware(RequestDelegate next) + { + _next = next; + } + + public async System.Threading.Tasks.Task InvokeAsync(HttpContext context) + { + if (!context.User.Identity.IsAuthenticated) + { + if (string.IsNullOrEmpty(context.User.FindFirstValue(ClaimTypes.Anonymous))) + { + var claim = new Claim(ClaimTypes.Anonymous, System.Guid.NewGuid().ToString()); + context.User.AddIdentity(new ClaimsIdentity(new[] { claim })); + + string scheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme; + await context.SignInAsync(scheme, context.User, new AuthenticationProperties { IsPersistent = false }); + } + } + + await _next(context); + } + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Controllers/GameController.cs b/Gameboard.ShogiUI.Sockets/Controllers/GameController.cs index 5d0becd..0f76e3b 100644 --- a/Gameboard.ShogiUI.Sockets/Controllers/GameController.cs +++ b/Gameboard.ShogiUI.Sockets/Controllers/GameController.cs @@ -1,61 +1,269 @@ -using Gameboard.ShogiUI.Sockets.Repositories; -using Gameboard.ShogiUI.Sockets.Repositories.RepositoryManagers; -using Gameboard.ShogiUI.Sockets.ServiceModels.Api.Messages; +using Gameboard.ShogiUI.Sockets.Extensions; +using Gameboard.ShogiUI.Sockets.Managers; +using Gameboard.ShogiUI.Sockets.Repositories; +using Gameboard.ShogiUI.Sockets.ServiceModels.Api; +using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; +using System.Security.Claims; using System.Threading.Tasks; namespace Gameboard.ShogiUI.Sockets.Controllers { - [Authorize] - [ApiController] - [Route("[controller]")] - public class GameController : ControllerBase - { - private readonly IGameboardRepositoryManager manager; - private readonly IGameboardRepository repository; - public GameController( - IGameboardRepository repository, - IGameboardRepositoryManager manager) - { - this.manager = manager; - this.repository = repository; - } + [ApiController] + [Route("[controller]")] + [Authorize(Roles = "Shogi")] + public class GameController : ControllerBase + { + private readonly IGameboardManager gameboardManager; + private readonly IGameboardRepository gameboardRepository; + private readonly ISocketConnectionManager communicationManager; - [Route("JoinCode")] - public async Task PostGameInvitation([FromBody] PostGameInvitation request) - { - var userName = HttpContext.User.Claims.First(c => c.Type == "preferred_username").Value; - var isPlayer1 = await manager.IsPlayer1(request.SessionName, userName); - if (isPlayer1) - { - var code = (await repository.PostJoinCode(request.SessionName, userName)).JoinCode; - return new CreatedResult("", new PostGameInvitationResponse(code)); - } - else - { - return new UnauthorizedResult(); - } - } + public GameController( + IGameboardRepository repository, + IGameboardManager manager, + ISocketConnectionManager communicationManager) + { + gameboardManager = manager; + gameboardRepository = repository; + this.communicationManager = communicationManager; + } - [AllowAnonymous] - [Route("GuestJoinCode")] - public async Task PostGuestGameInvitation([FromBody] PostGuestGameInvitation request) - { + [HttpPost("JoinCode")] + public async Task PostGameInvitation([FromBody] PostGameInvitation request) + { - var isGuest = manager.IsGuest(request.GuestId); - var isPlayer1 = manager.IsPlayer1(request.SessionName, request.GuestId); - if (isGuest && await isPlayer1) - { - var code = (await repository.PostJoinCode(request.SessionName, request.GuestId)).JoinCode; - return new CreatedResult("", new PostGameInvitationResponse(code)); - } - else - { - return new UnauthorizedResult(); - } - } - } + //var isPlayer1 = await gameboardManager.IsPlayer1(request.SessionName, userName); + //if (isPlayer1) + //{ + // var code = await gameboardRepository.PostJoinCode(request.SessionName, userName); + // return new CreatedResult("", new PostGameInvitationResponse(code)); + //} + //else + //{ + return new UnauthorizedResult(); + //} + } + + [AllowAnonymous] + [HttpPost("GuestJoinCode")] + public async Task PostGuestGameInvitation([FromBody] PostGuestGameInvitation request) + { + + //var isGuest = gameboardManager.IsGuest(request.GuestId); + //var isPlayer1 = gameboardManager.IsPlayer1(request.SessionName, request.GuestId); + //if (isGuest && await isPlayer1) + //{ + // var code = await gameboardRepository.PostJoinCode(request.SessionName, request.GuestId); + // return new CreatedResult("", new PostGameInvitationResponse(code)); + //} + //else + //{ + return new UnauthorizedResult(); + //} + } + + [HttpPost("{gameName}/Move")] + public async Task PostMove([FromRoute] string gameName, [FromBody] PostMove request) + { + var user = await gameboardManager.ReadUser(User); + var session = await gameboardRepository.ReadSession(gameName); + if (session == null) + { + return NotFound(); + } + if (user == null || (session.Player1.Id != user.Id && session.Player2?.Id != user.Id)) + { + return Forbid("User is not seated at this game."); + } + + var move = request.Move; + var moveModel = move.PieceFromCaptured.HasValue + ? new Models.Move(move.PieceFromCaptured.Value, move.To, move.IsPromotion) + : new Models.Move(move.From!, move.To, move.IsPromotion); + var moveSuccess = session.Shogi.Move(moveModel); + + if (moveSuccess) + { + var createSuccess = await gameboardRepository.CreateBoardState(session); + if (!createSuccess) + { + throw new ApplicationException("Unable to persist board state."); + } + await communicationManager.BroadcastToPlayers(new MoveResponse + { + GameName = session.Name, + PlayerName = user.Id + }, session.Player1.Id, session.Player2?.Id); + return Ok(); + } + return Conflict("Illegal move."); + } + + // TODO: Use JWT tokens for guests so they can authenticate and use API routes, too. + //[Route("")] + //public async Task PostSession([FromBody] PostSession request) + //{ + // var model = new Models.Session(request.Name, request.IsPrivate, request.Player1, request.Player2); + // var success = await repository.CreateSession(model); + // if (success) + // { + // var message = new ServiceModels.Socket.Messages.CreateGameResponse(ServiceModels.Types.ClientAction.CreateGame) + // { + // Game = model.ToServiceModel(), + // PlayerName = + // } + // var task = request.IsPrivate + // ? communicationManager.BroadcastToPlayers(response, userName) + // : communicationManager.BroadcastToAll(response); + // return new CreatedResult("", null); + // } + // return new ConflictResult(); + //} + + [HttpPost] + public async Task PostSession([FromBody] PostSession request) + { + var user = await ReadUserOrThrow(); + var session = new Models.SessionMetadata(request.Name, request.IsPrivate, user!); + var success = await gameboardRepository.CreateSession(session); + + if (success) + { + try + { + + await communicationManager.BroadcastToAll(new CreateGameResponse + { + Game = session.ToServiceModel(), + PlayerName = user.Id + }); + } + catch (Exception e) + { + Console.Error.WriteLine("Error broadcasting during PostSession"); + } + + return Ok(); + } + return Conflict(); + + } + + /// + /// Reads the board session and subscribes the caller to socket events for that session. + /// + [HttpGet("{gameName}")] + public async Task GetSession([FromRoute] string gameName) + { + var user = await ReadUserOrThrow(); + var session = await gameboardRepository.ReadSession(gameName); + if (session == null) + { + return NotFound(); + } + + var playerPerspective = WhichPerspective.Spectator; + if (session.Player1.Id == user.Id) + { + playerPerspective = WhichPerspective.Player1; + } + else if (session.Player2?.Id == user.Id) + { + playerPerspective = WhichPerspective.Player2; + } + + communicationManager.SubscribeToGame(session, user!.Id); + var response = new GetSessionResponse() + { + Game = new Models.SessionMetadata(session).ToServiceModel(), + BoardState = session.Shogi.ToServiceModel(), + MoveHistory = session.Shogi.MoveHistory.Select(_ => _.ToServiceModel()).ToList(), + PlayerPerspective = playerPerspective + }; + return new JsonResult(response); + } + + [HttpGet] + public async Task GetSessions() + { + var user = await ReadUserOrThrow(); + var sessions = await gameboardRepository.ReadSessionMetadatas(); + + var sessionsJoinedByUser = sessions + .Where(s => s.IsSeated(user)) + .Select(s => s.ToServiceModel()) + .ToList(); + var sessionsNotJoinedByUser = sessions + .Where(s => !s.IsSeated(user)) + .Select(s => s.ToServiceModel()) + .ToList(); + + return new GetSessionsResponse + { + PlayerHasJoinedSessions = new Collection(sessionsJoinedByUser), + AllOtherSessions = new Collection(sessionsNotJoinedByUser) + }; + } + + [HttpPut("{gameName}")] + public async Task PutJoinSession([FromRoute] string gameName) + { + var user = await ReadUserOrThrow(); + var session = await gameboardRepository.ReadSessionMetaData(gameName); + if (session == null) + { + return NotFound(); + } + if (session.Player2 != null) + { + return this.Conflict("This session already has two seated players and is full."); + } + + session.SetPlayer2(user); + var success = await gameboardRepository.UpdateSession(session); + if (!success) return this.Problem(detail: "Unable to update session."); + + var opponentName = user.Id == session.Player1.Id + ? session.Player2!.Id + : session.Player1.Id; + await communicationManager.BroadcastToPlayers(new JoinGameResponse + { + GameName = session.Name, + PlayerName = user.Id + }, opponentName); + return Ok(); + } + + [Authorize(Roles = "Admin, Shogi")] + [HttpDelete("{gameName}")] + public async Task DeleteSession([FromRoute] string gameName) + { + var user = await ReadUserOrThrow(); + if (user.IsAdmin) + { + return Ok(); + } + else + { + return Unauthorized(); + } + } + + private async Task ReadUserOrThrow() + { + var user = await gameboardManager.ReadUser(User); + if (user == null) + { + throw new UnauthorizedAccessException("Unknown user claims."); + } + return user; + } + } } diff --git a/Gameboard.ShogiUI.Sockets/Controllers/SocketController.cs b/Gameboard.ShogiUI.Sockets/Controllers/SocketController.cs index a729cdb..6f442bd 100644 --- a/Gameboard.ShogiUI.Sockets/Controllers/SocketController.cs +++ b/Gameboard.ShogiUI.Sockets/Controllers/SocketController.cs @@ -1,61 +1,115 @@ -using Gameboard.ShogiUI.Sockets.Managers; +using Gameboard.ShogiUI.Sockets.Extensions; +using Gameboard.ShogiUI.Sockets.Managers; +using Gameboard.ShogiUI.Sockets.Models; using Gameboard.ShogiUI.Sockets.Repositories; -using Gameboard.ShogiUI.Sockets.Repositories.RepositoryManagers; -using Gameboard.ShogiUI.Sockets.ServiceModels.Api.Messages; +using Gameboard.ShogiUI.Sockets.ServiceModels.Api; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using System.Linq; +using Microsoft.Extensions.Logging; +using System; +using System.Security.Claims; using System.Threading.Tasks; namespace Gameboard.ShogiUI.Sockets.Controllers { - [Authorize] - [Route("[controller]")] - [ApiController] - public class SocketController : ControllerBase - { - private readonly ISocketTokenManager tokenManager; - private readonly IGameboardRepository gameboardRepository; - private readonly IGameboardRepositoryManager gameboardManager; + [ApiController] + [Route("[controller]")] + [Authorize(Roles = "Shogi")] + public class SocketController : ControllerBase + { + private readonly ILogger logger; + private readonly ISocketTokenCache tokenCache; + private readonly IGameboardManager gameboardManager; + private readonly IGameboardRepository gameboardRepository; + private readonly ISocketConnectionManager connectionManager; + private readonly AuthenticationProperties authenticationProps; - public SocketController( - ISocketTokenManager tokenManager, - IGameboardRepository gameboardRepository, - IGameboardRepositoryManager gameboardManager) - { - this.tokenManager = tokenManager; - this.gameboardRepository = gameboardRepository; - this.gameboardManager = gameboardManager; - } + public SocketController( + ILogger logger, + ISocketTokenCache tokenCache, + IGameboardManager gameboardManager, + IGameboardRepository gameboardRepository, + ISocketConnectionManager connectionManager) + { + this.logger = logger; + this.tokenCache = tokenCache; + this.gameboardManager = gameboardManager; + this.gameboardRepository = gameboardRepository; + this.connectionManager = connectionManager; - [Route("Token")] - public IActionResult GetToken() - { - var userName = HttpContext.User.Claims.First(c => c.Type == "preferred_username").Value; - var token = tokenManager.GenerateToken(userName); - return new JsonResult(new GetTokenResponse(token)); - } + authenticationProps = new AuthenticationProperties + { + AllowRefresh = true, + IsPersistent = true + }; + } - [AllowAnonymous] - [Route("GuestToken")] - public async Task GetGuestToken([FromQuery] GetGuestToken request) - { - if (request.ClientId == null) - { - var clientId = await gameboardManager.CreateGuestUser(); - var token = tokenManager.GenerateToken(clientId); - return new JsonResult(new GetGuestTokenResponse(clientId, token)); - } - else - { - var response = await gameboardRepository.GetPlayer(request.ClientId); - if (response != null && response.Player != null) - { - var token = tokenManager.GenerateToken(response.Player.Name); - return new JsonResult(new GetGuestTokenResponse(response.Player.Name, token)); - } - } - return new UnauthorizedResult(); - } - } + [HttpGet("GuestLogout")] + [AllowAnonymous] + public async Task GuestLogout() + { + var signoutTask = HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + + var userId = User?.UserId(); + if (!string.IsNullOrEmpty(userId)) + { + connectionManager.UnsubscribeFromBroadcastAndGames(userId); + } + + await signoutTask; + return Ok(); + } + + [HttpGet("Token")] + public async Task GetToken() + { + var user = await gameboardManager.ReadUser(User); + if (user == null) + { + if (await gameboardManager.CreateUser(User)) + { + user = await gameboardManager.ReadUser(User); + } + } + + if (user == null) + { + return Unauthorized(); + } + + var token = tokenCache.GenerateToken(user.Id); + return new JsonResult(new GetTokenResponse(token)); + } + + [HttpGet("GuestToken")] + [AllowAnonymous] + public async Task GetGuestToken() + { + var user = await gameboardManager.ReadUser(User); + if (user == null) + { + // Create a guest user. + var newUser = Models.User.CreateGuestUser(Guid.NewGuid().ToString()); + var success = await gameboardRepository.CreateUser(newUser); + if (!success) + { + return Conflict(); + } + + var identity = newUser.CreateClaimsIdentity(); + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(identity), + authenticationProps + ); + user = newUser; + } + + var token = tokenCache.GenerateToken(user.Id.ToString()); + return this.Ok(new GetGuestTokenResponse(user.Id, user.DisplayName, token)); + } + } } diff --git a/Gameboard.ShogiUI.Sockets/Extensions/Extensions.cs b/Gameboard.ShogiUI.Sockets/Extensions/Extensions.cs new file mode 100644 index 0000000..ca6dc5c --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Extensions/Extensions.cs @@ -0,0 +1,28 @@ +using System.Linq; +using System.Security.Claims; + +namespace Gameboard.ShogiUI.Sockets.Extensions +{ + public static class Extensions + { + public static string? UserId(this ClaimsPrincipal self) + { + return self.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value; + } + + public static string? DisplayName(this ClaimsPrincipal self) + { + return self.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value; + } + + public static bool IsGuest(this ClaimsPrincipal self) + { + return self.HasClaim(c => c.Type == ClaimTypes.Role && c.Value == "Guest"); + } + + public static string ToCamelCase(this string self) + { + return char.ToLowerInvariant(self[0]) + self[1..]; + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Extensions/LogMiddleware.cs b/Gameboard.ShogiUI.Sockets/Extensions/LogMiddleware.cs index 3c39341..d4860c4 100644 --- a/Gameboard.ShogiUI.Sockets/Extensions/LogMiddleware.cs +++ b/Gameboard.ShogiUI.Sockets/Extensions/LogMiddleware.cs @@ -1,43 +1,50 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using System.IO; +using System.Text; using System.Threading.Tasks; namespace Gameboard.ShogiUI.Sockets.Extensions { - public class LogMiddleware - { - private readonly RequestDelegate next; - private readonly ILogger logger; + public class LogMiddleware + { + private readonly RequestDelegate next; + private readonly ILogger logger; - public LogMiddleware(RequestDelegate next, ILoggerFactory factory) - { - this.next = next; - logger = factory.CreateLogger(); - } - public async Task Invoke(HttpContext context) - { - try - { - await next(context); - } - finally - { - logger.LogInformation("Request {method} {url} => {statusCode}", - context.Request?.Method, - context.Request?.Path.Value, - context.Response?.StatusCode); - } - } - } + public LogMiddleware(RequestDelegate next, ILoggerFactory factory) + { + this.next = next; + logger = factory.CreateLogger(); + } - public static class IApplicationBuilderExtensions - { - public static IApplicationBuilder UseRequestResponseLogging(this IApplicationBuilder builder) - { - builder.UseMiddleware(); - return builder; - } - } + public async Task Invoke(HttpContext context) + { + try + { + await next(context); + } + finally + { + using var stream = new MemoryStream(); + context.Request?.Body.CopyToAsync(stream); + + logger.LogInformation("Request {method} {url} => {statusCode} \n Body: {body}", + context.Request?.Method, + context.Request?.Path.Value, + context.Response?.StatusCode, + Encoding.UTF8.GetString(stream.ToArray())); + } + } + } + + public static class IApplicationBuilderExtensions + { + public static IApplicationBuilder UseRequestResponseLogging(this IApplicationBuilder builder) + { + builder.UseMiddleware(); + return builder; + } + } } diff --git a/Gameboard.ShogiUI.Sockets/Extensions/ModelExtensions.cs b/Gameboard.ShogiUI.Sockets/Extensions/ModelExtensions.cs new file mode 100644 index 0000000..d5333f4 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Extensions/ModelExtensions.cs @@ -0,0 +1,63 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; +using System.Text; +using System.Text.RegularExpressions; + +namespace Gameboard.ShogiUI.Sockets.Extensions +{ + public static class ModelExtensions + { + public static string GetShortName(this Models.Piece self) + { + var name = self.WhichPiece switch + { + WhichPiece.King => " K ", + WhichPiece.GoldGeneral => " G ", + WhichPiece.SilverGeneral => self.IsPromoted ? "^S " : " S ", + WhichPiece.Bishop => self.IsPromoted ? "^B " : " B ", + WhichPiece.Rook => self.IsPromoted ? "^R " : " R ", + WhichPiece.Knight => self.IsPromoted ? "^k " : " k ", + WhichPiece.Lance => self.IsPromoted ? "^L " : " L ", + WhichPiece.Pawn => self.IsPromoted ? "^P " : " P ", + _ => " ? ", + }; + if (self.Owner == WhichPerspective.Player2) + name = Regex.Replace(name, @"([^\s]+)\s", "$1."); + return name; + } + + public static string PrintStateAsAscii(this Models.Shogi self) + { + var builder = new StringBuilder(); + builder.Append(" Player 2(.)"); + builder.AppendLine(); + for (var y = 8; y >= 0; y--) + { + builder.Append("- "); + for (var x = 0; x < 8; x++) builder.Append("- - "); + builder.Append("- -"); + builder.AppendLine(); + builder.Append('|'); + for (var x = 0; x < 9; x++) + { + var piece = self.Board[x, y]; + if (piece == null) + { + builder.Append(" "); + } + else + { + builder.AppendFormat("{0}", piece.GetShortName()); + } + builder.Append('|'); + } + builder.AppendLine(); + } + builder.Append("- "); + for (var x = 0; x < 8; x++) builder.Append("- - "); + builder.Append("- -"); + builder.AppendLine(); + builder.Append(" Player 1"); + return builder.ToString(); + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Gameboard.ShogiUI.Sockets.csproj b/Gameboard.ShogiUI.Sockets/Gameboard.ShogiUI.Sockets.csproj index 3f3669b..e05ef31 100644 --- a/Gameboard.ShogiUI.Sockets/Gameboard.ShogiUI.Sockets.csproj +++ b/Gameboard.ShogiUI.Sockets/Gameboard.ShogiUI.Sockets.csproj @@ -1,21 +1,27 @@  - net5.0 + net6.0 + true + 5 + enable - - - - - - - + + + + + + + + + + diff --git a/Gameboard.ShogiUI.Sockets/Gameboard.ShogiUI.Sockets.csproj.user b/Gameboard.ShogiUI.Sockets/Gameboard.ShogiUI.Sockets.csproj.user deleted file mode 100644 index 2adf92b..0000000 --- a/Gameboard.ShogiUI.Sockets/Gameboard.ShogiUI.Sockets.csproj.user +++ /dev/null @@ -1,12 +0,0 @@ - - - - ApiControllerEmptyScaffolder - root/Controller - AspShogiSockets - false - - - ProjectDebugger - - \ No newline at end of file diff --git a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/CreateGameHandler.cs b/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/CreateGameHandler.cs deleted file mode 100644 index b7e3773..0000000 --- a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/CreateGameHandler.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Gameboard.Shogi.Api.ServiceModels.Messages; -using Gameboard.ShogiUI.Sockets.Extensions; -using Gameboard.ShogiUI.Sockets.Repositories; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using System.Net.WebSockets; -using System.Threading.Tasks; - -namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers -{ - public class CreateGameHandler : IActionHandler - { - private readonly ILogger logger; - private readonly IGameboardRepository repository; - private readonly ISocketCommunicationManager communicationManager; - - public CreateGameHandler( - ILogger logger, - ISocketCommunicationManager communicationManager, - IGameboardRepository repository) - { - this.logger = logger; - this.repository = repository; - this.communicationManager = communicationManager; - } - - public async Task Handle(WebSocket socket, string json, string userName) - { - logger.LogInformation("Socket Request \n{0}\n", new[] { json }); - var request = JsonConvert.DeserializeObject(json); - var postSessionResponse = await repository.PostSession(new PostSession - { - SessionName = request.GameName, - PlayerName = userName, // TODO : Investigate if needed by UI - IsPrivate = request.IsPrivate - }); - - var response = new CreateGameResponse(request.Action) - { - PlayerName = userName, - Game = new Game - { - GameName = postSessionResponse.SessionName, - Players = new string[] { userName } - } - }; - - if (string.IsNullOrWhiteSpace(postSessionResponse.SessionName)) - { - response.Error = "Game already exists."; - } - - var serialized = JsonConvert.SerializeObject(response); - logger.LogInformation("Socket Response \n{0}\n", new[] { serialized }); - if (request.IsPrivate) - { - await socket.SendTextAsync(serialized); - } - else - { - await communicationManager.BroadcastToAll(serialized); - } - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/IActionHandler.cs b/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/IActionHandler.cs deleted file mode 100644 index 5168598..0000000 --- a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/IActionHandler.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; -using System.Net.WebSockets; -using System.Threading.Tasks; - -namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers -{ - public interface IActionHandler - { - /// - /// Responsible for parsing json and handling the request. - /// - Task Handle(WebSocket socket, string json, string userName); - } - - public delegate IActionHandler ActionHandlerResolver(ClientAction action); -} diff --git a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/JoinByCodeHandler.cs b/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/JoinByCodeHandler.cs index 102564a..2dcac05 100644 --- a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/JoinByCodeHandler.cs +++ b/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/JoinByCodeHandler.cs @@ -1,75 +1,64 @@ -using Gameboard.Shogi.Api.ServiceModels.Messages; -using Gameboard.ShogiUI.Sockets.Extensions; -using Gameboard.ShogiUI.Sockets.Repositories; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using System.Net.WebSockets; +using Gameboard.ShogiUI.Sockets.Repositories; +using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; using System.Threading.Tasks; namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers { - public class JoinByCodeHandler : IActionHandler + public interface IJoinByCodeHandler + { + Task Handle(JoinByCodeRequest request, string userName); + } + public class JoinByCodeHandler : IJoinByCodeHandler { - private readonly ILogger logger; private readonly IGameboardRepository repository; - private readonly ISocketCommunicationManager communicationManager; + private readonly ISocketConnectionManager communicationManager; public JoinByCodeHandler( - ILogger logger, - ISocketCommunicationManager communicationManager, + ISocketConnectionManager communicationManager, IGameboardRepository repository) { - this.logger = logger; this.repository = repository; this.communicationManager = communicationManager; } - public async Task Handle(WebSocket socket, string json, string userName) + public async Task Handle(JoinByCodeRequest request, string userName) { - logger.LogInformation("Socket Request \n{0}\n", new[] { json }); - var request = JsonConvert.DeserializeObject(json); - var joinGameResponse = await repository.PostJoinPrivateSession(new PostJoinPrivateSession - { - PlayerName = userName, - JoinCode = request.JoinCode - }); + //var request = JsonConvert.DeserializeObject(json); + //var sessionName = await repository.PostJoinPrivateSession(new PostJoinPrivateSession + //{ + // PlayerName = userName, + // JoinCode = request.JoinCode + //}); - if (joinGameResponse.JoinSucceeded) - { - var gameName = (await repository.GetGame(joinGameResponse.SessionName)).Session.Name; + //if (sessionName == null) + //{ + // var response = new JoinGameResponse(ClientAction.JoinByCode) + // { + // PlayerName = userName, + // GameName = sessionName, + // Error = "Error joining game." + // }; + // await communicationManager.BroadcastToPlayers(response, userName); + //} + //else + //{ + // // Other members of the game see a regular JoinGame occur. + // var response = new JoinGameResponse(ClientAction.JoinGame) + // { + // PlayerName = userName, + // GameName = sessionName + // }; + // // At this time, userName hasn't subscribed and won't receive this message. + // await communicationManager.BroadcastToGame(sessionName, response); - // Other members of the game see a regular JoinGame occur. - var response = new JoinGameResponse(ClientAction.JoinGame) - { - PlayerName = userName, - GameName = gameName - }; - var serialized = JsonConvert.SerializeObject(response); - await communicationManager.BroadcastToGame(gameName, serialized); - communicationManager.SubscribeToGame(socket, gameName, userName); - - // But the player joining sees the JoinByCode occur. - response = new JoinGameResponse(ClientAction.JoinByCode) - { - PlayerName = userName, - GameName = gameName - }; - serialized = JsonConvert.SerializeObject(response); - await socket.SendTextAsync(serialized); - } - else - { - var response = new JoinGameResponse(ClientAction.JoinByCode) - { - PlayerName = userName, - Error = "Error joining game." - }; - var serialized = JsonConvert.SerializeObject(response); - logger.LogInformation("Socket Response \n{0}\n", new[] { serialized }); - await socket.SendTextAsync(serialized); - } + // // The player joining sees the JoinByCode occur. + // response = new JoinGameResponse(ClientAction.JoinByCode) + // { + // PlayerName = userName, + // GameName = sessionName + // }; + // await communicationManager.BroadcastToPlayers(response, userName); + //} } } } diff --git a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/JoinGameHandler.cs b/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/JoinGameHandler.cs deleted file mode 100644 index c435d63..0000000 --- a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/JoinGameHandler.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Gameboard.Shogi.Api.ServiceModels.Messages; -using Gameboard.ShogiUI.Sockets.Repositories; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using System.Net.WebSockets; -using System.Threading.Tasks; - -namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers -{ - public class JoinGameHandler : IActionHandler - { - private readonly ILogger logger; - private readonly IGameboardRepository gameboardRepository; - private readonly ISocketCommunicationManager communicationManager; - public JoinGameHandler( - ILogger logger, - ISocketCommunicationManager communicationManager, - IGameboardRepository gameboardRepository) - { - this.logger = logger; - this.gameboardRepository = gameboardRepository; - this.communicationManager = communicationManager; - } - - public async Task Handle(WebSocket socket, string json, string userName) - { - logger.LogInformation("Socket Request \n{0}\n", new[] { json }); - var request = JsonConvert.DeserializeObject(json); - var response = new JoinGameResponse(ClientAction.JoinGame) - { - PlayerName = userName - }; - - var joinGameResponse = await gameboardRepository.PutJoinPublicSession(request.GameName, new PutJoinPublicSession - { - PlayerName = userName, - SessionName = request.GameName - }); - - if (joinGameResponse.JoinSucceeded) - { - response.GameName = request.GameName; - } - else - { - response.Error = "Game is full or code is incorrect."; - } - var serialized = JsonConvert.SerializeObject(response); - logger.LogInformation("Socket Response \n{0}\n", new[] { serialized }); - await communicationManager.BroadcastToAll(serialized); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/ListGamesHandler.cs b/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/ListGamesHandler.cs deleted file mode 100644 index 72050fc..0000000 --- a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/ListGamesHandler.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Gameboard.ShogiUI.Sockets.Extensions; -using Gameboard.ShogiUI.Sockets.Repositories; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using System; -using System.Linq; -using System.Net.WebSockets; -using System.Threading.Tasks; - -namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers -{ - public class ListGamesHandler : IActionHandler - { - private readonly ILogger logger; - private readonly IGameboardRepository repository; - - public ListGamesHandler( - ILogger logger, - IGameboardRepository repository) - { - this.logger = logger; - this.repository = repository; - } - - public async Task Handle(WebSocket socket, string json, string userName) - { - logger.LogInformation("Socket Request \n{0}\n", new[] { json }); - var request = JsonConvert.DeserializeObject(json); - var getGamesResponse = string.IsNullOrWhiteSpace(userName) - ? await repository.GetGames() - : await repository.GetGames(userName); - - var games = getGamesResponse.Sessions - .OrderBy(s => s.Player1 == userName || s.Player2 == userName) - .Select(s => - { - var players = new[] { s.Player1, s.Player2 } - .Where(p => !string.IsNullOrWhiteSpace(p)) - .ToArray(); - return new Game { GameName = s.Name, Players = players }; - }); - var response = new ListGamesResponse(ClientAction.ListGames) - { - Games = games ?? Array.Empty() - }; - - var serialized = JsonConvert.SerializeObject(response); - logger.LogInformation("Socket Response \n{0}\n", new[] { serialized }); - await socket.SendTextAsync(serialized); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/LoadGameHandler.cs b/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/LoadGameHandler.cs deleted file mode 100644 index 97e7c32..0000000 --- a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/LoadGameHandler.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Gameboard.ShogiUI.Sockets.Extensions; -using Gameboard.ShogiUI.Sockets.Managers.Utility; -using Gameboard.ShogiUI.Sockets.Repositories; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using System.Linq; -using System.Net.WebSockets; -using System.Threading.Tasks; - -namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers -{ - public class LoadGameHandler : IActionHandler - { - private readonly ILogger logger; - private readonly IGameboardRepository gameboardRepository; - private readonly ISocketCommunicationManager communicationManager; - - public LoadGameHandler( - ILogger logger, - ISocketCommunicationManager communicationManager, - IGameboardRepository gameboardRepository) - { - this.logger = logger; - this.gameboardRepository = gameboardRepository; - this.communicationManager = communicationManager; - } - - public async Task Handle(WebSocket socket, string json, string userName) - { - logger.LogInformation("Socket Request \n{0}\n", json); - var request = JsonConvert.DeserializeObject(json); - var response = new LoadGameResponse(ClientAction.LoadGame); - var getGameResponse = await gameboardRepository.GetGame(request.GameName); - var getMovesResponse = await gameboardRepository.GetMoves(request.GameName); - - if (getGameResponse == null || getMovesResponse == null) - { - response.Error = $"Could not find game."; - } - else - { - var session = getGameResponse.Session; - var players = new[] { session.Player1, session.Player2 } - .Where(p => !string.IsNullOrWhiteSpace(p)) - .ToArray(); - response.Game = new Game { GameName = session.Name, Players = players }; - - response.Moves = userName.Equals(session.Player1) - ? getMovesResponse.Moves.Select(_ => Mapper.Map(_)) - : getMovesResponse.Moves.Select(_ => Move.ConvertPerspective(Mapper.Map(_))); - - communicationManager.SubscribeToGame(socket, session.Name, userName); - } - - var serialized = JsonConvert.SerializeObject(response); - logger.LogInformation("Socket Response \n{0}\n", serialized); - await socket.SendTextAsync(serialized); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/MoveHandler.cs b/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/MoveHandler.cs deleted file mode 100644 index bbce0c5..0000000 --- a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/MoveHandler.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Gameboard.Shogi.Api.ServiceModels.Messages; -using Gameboard.ShogiUI.Sockets.Extensions; -using Gameboard.ShogiUI.Sockets.Managers.Utility; -using Gameboard.ShogiUI.Sockets.Repositories; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Messages; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using System.Net.WebSockets; -using System.Threading.Tasks; - -namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers -{ - public class MoveHandler : IActionHandler - { - private readonly ILogger logger; - private readonly IGameboardRepository gameboardRepository; - private readonly ISocketCommunicationManager communicationManager; - public MoveHandler( - ILogger logger, - ISocketCommunicationManager communicationManager, - IGameboardRepository gameboardRepository) - { - this.logger = logger; - this.gameboardRepository = gameboardRepository; - this.communicationManager = communicationManager; - } - - public async Task Handle(WebSocket socket, string json, string userName) - { - logger.LogInformation("Socket Request \n{0}\n", new[] { json }); - var request = JsonConvert.DeserializeObject(json); - // Basic move validation - var move = request.Move; - if (move.To.Equals(move.From)) - { - var serialized = JsonConvert.SerializeObject( - new ErrorResponse(ClientAction.Move) - { - Error = "Error: moving piece from tile to the same tile." - }); - await socket.SendTextAsync(serialized); - return; - } - - var getSessionResponse = await gameboardRepository.GetGame(request.GameName); - var isPlayer1 = userName == getSessionResponse.Session.Player1; - if (!isPlayer1) - { - // Convert the move coords to player1 perspective. - move = Move.ConvertPerspective(move); - } - - await gameboardRepository.PostMove(request.GameName, new PostMove(Mapper.Map(move))); - - var response = new MoveResponse(ClientAction.Move) - { - GameName = request.GameName, - PlayerName = userName - }; - await communicationManager.BroadcastToGame( - request.GameName, - (playerName, sslStream) => - { - response.Move = playerName.Equals(userName) - ? request.Move - : Move.ConvertPerspective(request.Move); - var serialized = JsonConvert.SerializeObject(response); - logger.LogInformation("Socket Response \n{0}\n", new[] { serialized }); - return serialized; - } - ); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Managers/GameboardManager.cs b/Gameboard.ShogiUI.Sockets/Managers/GameboardManager.cs new file mode 100644 index 0000000..5cc9f04 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Managers/GameboardManager.cs @@ -0,0 +1,74 @@ +using Gameboard.ShogiUI.Sockets.Extensions; +using Gameboard.ShogiUI.Sockets.Models; +using Gameboard.ShogiUI.Sockets.Repositories; +using System; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace Gameboard.ShogiUI.Sockets.Managers +{ + public interface IGameboardManager + { + Task AssignPlayer2ToSession(string sessionName, User user); + Task ReadUser(ClaimsPrincipal user); + Task CreateUser(ClaimsPrincipal user); + } + + public class GameboardManager : IGameboardManager + { + private readonly IGameboardRepository repository; + + public GameboardManager(IGameboardRepository repository) + { + this.repository = repository; + } + + public Task CreateUser(ClaimsPrincipal principal) + { + var id = principal.UserId(); + if (string.IsNullOrEmpty(id)) + { + return Task.FromResult(false); + } + + var user = principal.IsGuest() + ? User.CreateGuestUser(id) + : User.CreateMsalUser(id); + + return repository.CreateUser(user); + } + + public Task ReadUser(ClaimsPrincipal principal) + { + var userId = principal.UserId(); + if (!string.IsNullOrEmpty(userId)) + { + return repository.ReadUser(userId); + } + + return Task.FromResult(null); + } + + + public async Task CreateJoinCode(string sessionName, string playerName) + { + //var session = await repository.GetGame(sessionName); + //if (playerName == session?.Player1) + //{ + // return await repository.PostJoinCode(sessionName, playerName); + //} + return string.Empty; + } + + public async Task AssignPlayer2ToSession(string sessionName, User user) + { + var session = await repository.ReadSessionMetaData(sessionName); + if (session != null && !session.IsPrivate && session.Player2 == null) + { + session.SetPlayer2(user); + return await repository.UpdateSession(session); + } + return false; + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Managers/SocketCommunicationManager.cs b/Gameboard.ShogiUI.Sockets/Managers/SocketCommunicationManager.cs deleted file mode 100644 index 8e68a7a..0000000 --- a/Gameboard.ShogiUI.Sockets/Managers/SocketCommunicationManager.cs +++ /dev/null @@ -1,172 +0,0 @@ -using Gameboard.ShogiUI.Sockets.Extensions; -using Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers; -using Gameboard.ShogiUI.Sockets.Managers.Utility; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Net.WebSockets; -using System.Threading.Tasks; - -namespace Gameboard.ShogiUI.Sockets.Managers -{ - public interface ISocketCommunicationManager - { - Task CommunicateWith(WebSocket w, string s); - Task BroadcastToAll(string msg); - Task BroadcastToGame(string gameName, Func msgBuilder); - Task BroadcastToGame(string gameName, string msg); - void SubscribeToGame(WebSocket socket, string gameName, string playerName); - void SubscribeToBroadcast(WebSocket socket, string playerName); - void UnsubscribeFromBroadcastAndGames(string playerName); - void UnsubscribeFromGame(string gameName, string playerName); - } - - public class SocketCommunicationManager : ISocketCommunicationManager - { - private readonly ConcurrentDictionary connections; - private readonly ConcurrentDictionary> gameSeats; - private readonly ILogger logger; - private readonly ActionHandlerResolver handlerResolver; - - public SocketCommunicationManager( - ILogger logger, - ActionHandlerResolver handlerResolver) - { - this.logger = logger; - this.handlerResolver = handlerResolver; - connections = new ConcurrentDictionary(); - gameSeats = new ConcurrentDictionary>(); - } - - public async Task CommunicateWith(WebSocket socket, string userName) - { - SubscribeToBroadcast(socket, userName); - - while (!socket.CloseStatus.HasValue) - { - try - { - var message = await socket.ReceiveTextAsync(); - if (string.IsNullOrWhiteSpace(message)) continue; - - var request = JsonConvert.DeserializeObject(message); - if (!Enum.IsDefined(typeof(ClientAction), request.Action)) - { - await socket.SendTextAsync("Error: Action not recognized."); - } - else - { - var handler = handlerResolver(request.Action); - await handler.Handle(socket, message, userName); - } - } - catch (OperationCanceledException ex) - { - logger.LogError(ex.Message); - } - catch(WebSocketException ex) - { - logger.LogInformation($"{nameof(WebSocketException)} in {nameof(SocketCommunicationManager)}."); - logger.LogInformation("Probably tried writing to a closed socket."); - logger.LogError(ex.Message); - } - } - UnsubscribeFromBroadcastAndGames(userName); - } - - public void SubscribeToBroadcast(WebSocket socket, string playerName) - { - logger.LogInformation("Subscribing [{0}] to broadcast", playerName); - connections.TryAdd(playerName, socket); - } - - public void UnsubscribeFromBroadcastAndGames(string playerName) - { - logger.LogInformation("Unsubscribing [{0}] from broadcast", playerName); - connections.TryRemove(playerName, out _); - foreach (var game in gameSeats) - { - game.Value.Remove(playerName); - } - } - - /// - /// Unsubscribes the player from their current game, then subscribes to the new game. - /// - public void SubscribeToGame(WebSocket socket, string gameName, string playerName) - { - // Unsubscribe from any other games - foreach (var kvp in gameSeats) - { - var gameNameKey = kvp.Key; - UnsubscribeFromGame(gameNameKey, playerName); - } - - // Subscribe - logger.LogInformation("Subscribing player [{0}] to game [{1}]", playerName, gameName); - var addSuccess = gameSeats.TryAdd(gameName, new List { playerName }); - if (!addSuccess && !gameSeats[gameName].Contains(playerName)) - { - gameSeats[gameName].Add(playerName); - } - } - - public void UnsubscribeFromGame(string gameName, string playerName) - { - if (gameSeats.ContainsKey(gameName)) - { - logger.LogInformation("Unsubscribing player [{0}] from game [{1}]", playerName, gameName); - gameSeats[gameName].Remove(playerName); - if (gameSeats[gameName].Count == 0) gameSeats.TryRemove(gameName, out _); - } - } - - public async Task BroadcastToAll(string msg) - { - var tasks = connections.Select(kvp => - { - var player = kvp.Key; - var socket = kvp.Value; - logger.LogInformation("Broadcasting to player [{0}] \n{1}\n", new[] { player, msg }); - return socket.SendTextAsync(msg); - }); - await Task.WhenAll(tasks); - } - - public async Task BroadcastToGame(string gameName, string msg) - { - if (gameSeats.ContainsKey(gameName)) - { - var tasks = gameSeats[gameName] - .Select(playerName => - { - logger.LogInformation("Broadcasting to game [{0}], player [{0}] \n{1}\n", gameName, playerName, msg); - return connections[playerName]; - }) - .Where(stream => stream != null) - .Select(socket => socket.SendTextAsync(msg)); - await Task.WhenAll(tasks); - } - } - - public async Task BroadcastToGame(string gameName, Func msgBuilder) - { - if (gameSeats.ContainsKey(gameName)) - { - var tasks = gameSeats[gameName] - .Select(playerName => - { - var socket = connections[playerName]; - var msg = msgBuilder(playerName, socket); - logger.LogInformation("Broadcasting to game [{0}], player [{0}] \n{1}\n", gameName, playerName, msg); - return socket.SendTextAsync(msg); - }); - await Task.WhenAll(tasks); - } - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Managers/SocketConnectionManager.cs b/Gameboard.ShogiUI.Sockets/Managers/SocketConnectionManager.cs index 0e5d729..0b2a5b8 100644 --- a/Gameboard.ShogiUI.Sockets/Managers/SocketConnectionManager.cs +++ b/Gameboard.ShogiUI.Sockets/Managers/SocketConnectionManager.cs @@ -1,44 +1,162 @@ -using Microsoft.AspNetCore.Http; +using Gameboard.ShogiUI.Sockets.Extensions; +using Gameboard.ShogiUI.Sockets.Models; +using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; using System; -using System.Net; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net.WebSockets; using System.Threading.Tasks; namespace Gameboard.ShogiUI.Sockets.Managers { - public interface ISocketConnectionManager - { - Task HandleSocketRequest(HttpContext context); - } + public interface ISocketConnectionManager + { + Task BroadcastToAll(IResponse response); + //Task BroadcastToGame(string gameName, IResponse response); + //Task BroadcastToGame(string gameName, IResponse forPlayer1, IResponse forPlayer2); + void SubscribeToGame(Session session, string playerName); + void SubscribeToBroadcast(WebSocket socket, string playerName); + void UnsubscribeFromBroadcastAndGames(string playerName); + void UnsubscribeFromGame(string gameName, string playerName); + Task BroadcastToPlayers(IResponse response, params string?[] playerNames); + } - public class SocketConnectionManager : ISocketConnectionManager - { - private readonly ISocketCommunicationManager communicationManager; - private readonly ISocketTokenManager tokenManager; + /// + /// Retains all active socket connections and provides convenient methods for sending messages to clients. + /// + public class SocketConnectionManager : ISocketConnectionManager + { + /// Dictionary key is player name. + private readonly ConcurrentDictionary connections; + /// Dictionary key is game name. + private readonly ConcurrentDictionary sessions; + private readonly ILogger logger; - public SocketConnectionManager(ISocketCommunicationManager communicationManager, ISocketTokenManager tokenManager) : base() - { - this.communicationManager = communicationManager; - this.tokenManager = tokenManager; + public SocketConnectionManager(ILogger logger) + { + this.logger = logger; + connections = new ConcurrentDictionary(); + sessions = new ConcurrentDictionary(); + } - } + public void SubscribeToBroadcast(WebSocket socket, string playerName) + { + connections.TryRemove(playerName, out var _); + connections.TryAdd(playerName, socket); + } - public async Task HandleSocketRequest(HttpContext context) - { - var hasToken = context.Request.Query.Keys.Contains("token"); - if (hasToken) - { - var oneTimeToken = context.Request.Query["token"][0]; - var tokenAsGuid = Guid.Parse(oneTimeToken); - var userName = tokenManager.GetUsername(tokenAsGuid); - if (!string.IsNullOrEmpty(userName)) - { - var socket = await context.WebSockets.AcceptWebSocketAsync(); - await communicationManager.CommunicateWith(socket, userName); - return; - } - } - context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; - return; - } - } + public void UnsubscribeFromBroadcastAndGames(string playerName) + { + connections.TryRemove(playerName, out _); + foreach (var kvp in sessions) + { + var sessionName = kvp.Key; + UnsubscribeFromGame(sessionName, playerName); + } + } + + /// + /// Unsubscribes the player from their current game, then subscribes to the new game. + /// + public void SubscribeToGame(Session session, string playerName) + { + // Unsubscribe from any other games + foreach (var kvp in sessions) + { + var gameNameKey = kvp.Key; + UnsubscribeFromGame(gameNameKey, playerName); + } + + // Subscribe + if (connections.TryGetValue(playerName, out var socket)) + { + var s = sessions.GetOrAdd(session.Name, session); + s.Subscriptions.TryAdd(playerName, socket); + } + } + + public void UnsubscribeFromGame(string gameName, string playerName) + { + if (sessions.TryGetValue(gameName, out var s)) + { + s.Subscriptions.TryRemove(playerName, out _); + if (s.Subscriptions.IsEmpty) sessions.TryRemove(gameName, out _); + } + } + + public async Task BroadcastToPlayers(IResponse response, params string?[] playerNames) + { + var tasks = new List(playerNames.Length); + foreach (var name in playerNames) + { + if (!string.IsNullOrEmpty(name) && connections.TryGetValue(name, out var socket)) + { + var serialized = JsonConvert.SerializeObject(response); + logger.LogInformation("Response to {0} \n{1}\n", name, serialized); + tasks.Add(socket.SendTextAsync(serialized)); + } + } + await Task.WhenAll(tasks); + } + public Task BroadcastToAll(IResponse response) + { + var message = JsonConvert.SerializeObject(response); + logger.LogInformation($"Broadcasting\n{0}", message); + var tasks = new List(connections.Count); + foreach (var kvp in connections) + { + var socket = kvp.Value; + try + { + + tasks.Add(socket.SendTextAsync(message)); + } + catch (WebSocketException webSocketException) + { + logger.LogInformation("Tried sending a message to socket connection for user [{user}], but found the connection has closed.", kvp.Key); + UnsubscribeFromBroadcastAndGames(kvp.Key); + } + catch (Exception exception) + { + logger.LogInformation("Tried sending a message to socket connection for user [{user}], but found the connection has closed.", kvp.Key); + UnsubscribeFromBroadcastAndGames(kvp.Key); + } + } + try + { + var task = Task.WhenAll(tasks); + return task; + } + catch (Exception e) + { + Console.WriteLine("Yo"); + } + return Task.FromResult(0); + } + + //public Task BroadcastToGame(string gameName, IResponse forPlayer1, IResponse forPlayer2) + //{ + // if (sessions.TryGetValue(gameName, out var session)) + // { + // var serialized1 = JsonConvert.SerializeObject(forPlayer1); + // var serialized2 = JsonConvert.SerializeObject(forPlayer2); + // return Task.WhenAll( + // session.SendToPlayer1(serialized1), + // session.SendToPlayer2(serialized2)); + // } + // return Task.CompletedTask; + //} + + //public Task BroadcastToGame(string gameName, IResponse messageForAllPlayers) + //{ + // if (sessions.TryGetValue(gameName, out var session)) + // { + // var serialized = JsonConvert.SerializeObject(messageForAllPlayers); + // return session.Broadcast(serialized); + // } + // return Task.CompletedTask; + //} + } } diff --git a/Gameboard.ShogiUI.Sockets/Managers/SocketTokenCache.cs b/Gameboard.ShogiUI.Sockets/Managers/SocketTokenCache.cs new file mode 100644 index 0000000..722dc33 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Managers/SocketTokenCache.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Gameboard.ShogiUI.Sockets.Managers +{ + public interface ISocketTokenCache + { + Guid GenerateToken(string s); + string? GetUsername(Guid g); + } + + public class SocketTokenCache : ISocketTokenCache + { + /// + /// Key is userName or webSessionId + /// + private readonly ConcurrentDictionary Tokens; + + public SocketTokenCache() + { + Tokens = new ConcurrentDictionary(); + } + + public Guid GenerateToken(string userName) + { + Tokens.Remove(userName, out _); + + var guid = Guid.NewGuid(); + Tokens.TryAdd(userName, guid); + + _ = Task.Run(async () => + { + await Task.Delay(TimeSpan.FromMinutes(1)); + Tokens.Remove(userName, out _); + }); + + return guid; + } + + /// User name associated to the guid or null. + public string? GetUsername(Guid guid) + { + var userName = Tokens.FirstOrDefault(kvp => kvp.Value == guid).Key; + if (userName != null) + { + Tokens.Remove(userName, out _); + } + return userName; + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Managers/SocketTokenManager.cs b/Gameboard.ShogiUI.Sockets/Managers/SocketTokenManager.cs deleted file mode 100644 index 971e438..0000000 --- a/Gameboard.ShogiUI.Sockets/Managers/SocketTokenManager.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace Gameboard.ShogiUI.Sockets.Managers -{ - public interface ISocketTokenManager - { - Guid GenerateToken(string s); - string GetUsername(Guid g); - } - - public class SocketTokenManager : ISocketTokenManager - { - /// - /// Key is userName - /// - private readonly Dictionary Tokens; - - public SocketTokenManager() - { - Tokens = new Dictionary(); - } - - public Guid GenerateToken(string userName) - { - var guid = Guid.NewGuid(); - - if (Tokens.ContainsKey(userName)) - { - Tokens.Remove(userName); - } - Tokens.Add(userName, guid); - - _ = Task.Run(async () => - { - await Task.Delay(TimeSpan.FromMinutes(1)); - Tokens.Remove(userName); - }); - - return guid; - } - - /// User name associated to the guid or null. - public string GetUsername(Guid guid) - { - if (Tokens.ContainsValue(guid)) - { - var username = Tokens.First(kvp => kvp.Value == guid).Key; - Tokens.Remove(username); - return username; - } - return null; - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Managers/Utility/Mapper.cs b/Gameboard.ShogiUI.Sockets/Managers/Utility/Mapper.cs deleted file mode 100644 index 9d84fac..0000000 --- a/Gameboard.ShogiUI.Sockets/Managers/Utility/Mapper.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; -using Microsoft.FSharp.Core; -using GameboardTypes = Gameboard.Shogi.Api.ServiceModels.Types; - -namespace Gameboard.ShogiUI.Sockets.Managers.Utility -{ - public static class Mapper - { - public static GameboardTypes.Move Map(Move source) - { - var from = source.From; - var to = source.To; - FSharpOption pieceFromCaptured = source.PieceFromCaptured switch - { - "B" => new FSharpOption(GameboardTypes.PieceName.Bishop), - "G" => new FSharpOption(GameboardTypes.PieceName.GoldenGeneral), - "K" => new FSharpOption(GameboardTypes.PieceName.King), - "k" => new FSharpOption(GameboardTypes.PieceName.Knight), - "L" => new FSharpOption(GameboardTypes.PieceName.Lance), - "P" => new FSharpOption(GameboardTypes.PieceName.Pawn), - "R" => new FSharpOption(GameboardTypes.PieceName.Rook), - "S" => new FSharpOption(GameboardTypes.PieceName.SilverGeneral), - _ => null - }; - var target = new GameboardTypes.Move - { - Origin = new GameboardTypes.BoardLocation { X = from.X, Y = from.Y }, - Destination = new GameboardTypes.BoardLocation { X = to.X, Y = to.Y }, - IsPromotion = source.IsPromotion, - PieceFromCaptured = pieceFromCaptured - }; - return target; - } - - public static Move Map(GameboardTypes.Move source) - { - var origin = source.Origin; - var destination = source.Destination; - string pieceFromCaptured = null; - if (source.PieceFromCaptured != null) - { - pieceFromCaptured = source.PieceFromCaptured.Value switch - { - GameboardTypes.PieceName.Bishop => "B", - GameboardTypes.PieceName.GoldenGeneral => "G", - GameboardTypes.PieceName.King => "K", - GameboardTypes.PieceName.Knight => "k", - GameboardTypes.PieceName.Lance => "L", - GameboardTypes.PieceName.Pawn => "P", - GameboardTypes.PieceName.Rook => "R", - GameboardTypes.PieceName.SilverGeneral => "S", - _ => "" - }; - } - - var target = new Move - { - From = new Coords { X = origin.X, Y = origin.Y }, - To = new Coords { X = destination.X, Y = destination.Y }, - IsPromotion = source.IsPromotion, - PieceFromCaptured = pieceFromCaptured - }; - - return target; - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Managers/Utility/Request.cs b/Gameboard.ShogiUI.Sockets/Managers/Utility/Request.cs deleted file mode 100644 index df3f245..0000000 --- a/Gameboard.ShogiUI.Sockets/Managers/Utility/Request.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Interfaces; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; - -namespace Gameboard.ShogiUI.Sockets.Managers.Utility -{ - public class Request : IRequest - { - public ClientAction Action { get; set; } - public string PlayerName { get; set; } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Models/Move.cs b/Gameboard.ShogiUI.Sockets/Models/Move.cs new file mode 100644 index 0000000..bbb32f7 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Models/Move.cs @@ -0,0 +1,63 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; +using Gameboard.ShogiUI.Sockets.Utilities; +using System.Diagnostics; +using System.Numerics; + +namespace Gameboard.ShogiUI.Sockets.Models +{ + [DebuggerDisplay("{From} - {To}")] + public class Move + { + public Vector2? From { get; } // TODO: Use string notation + public bool IsPromotion { get; } + public WhichPiece? PieceFromHand { get; } + public Vector2 To { get; } + + public Move(Vector2 from, Vector2 to, bool isPromotion = false) + { + From = from; + To = to; + IsPromotion = isPromotion; + } + public Move(WhichPiece pieceFromHand, Vector2 to) + { + PieceFromHand = pieceFromHand; + To = to; + } + + /// + /// Constructor to represent moving a piece on the Board to another position on the Board. + /// + /// Position the piece is being moved from. + /// Position the piece is being moved to. + /// If the moving piece should be promoted. + public Move(string fromNotation, string toNotation, bool isPromotion = false) + { + From = NotationHelper.FromBoardNotation(fromNotation); + To = NotationHelper.FromBoardNotation(toNotation); + IsPromotion = isPromotion; + } + + /// + /// Constructor to represent moving a piece from the Hand to the Board. + /// + /// The piece being moved from the Hand to the Board. + /// Position the piece is being moved to. + /// If the moving piece should be promoted. + public Move(WhichPiece pieceFromHand, string toNotation, bool isPromotion = false) + { + From = null; + PieceFromHand = pieceFromHand; + To = NotationHelper.FromBoardNotation(toNotation); + IsPromotion = isPromotion; + } + + public ServiceModels.Types.Move ToServiceModel() => new() + { + From = From.HasValue ? NotationHelper.ToBoardNotation(From.Value) : null, + IsPromotion = IsPromotion, + PieceFromCaptured = PieceFromHand.HasValue ? PieceFromHand : null, + To = NotationHelper.ToBoardNotation(To) + }; + } +} diff --git a/Gameboard.ShogiUI.Sockets/Models/MoveSets.cs b/Gameboard.ShogiUI.Sockets/Models/MoveSets.cs new file mode 100644 index 0000000..5f4ee8e --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Models/MoveSets.cs @@ -0,0 +1,95 @@ +using PathFinding; +using System.Collections.Generic; + +namespace Gameboard.ShogiUI.Sockets.Models +{ + public static class MoveSets + { + public static readonly List King = new(8) + { + new PathFinding.Move(Direction.Up), + new PathFinding.Move(Direction.Left), + new PathFinding.Move(Direction.Right), + new PathFinding.Move(Direction.Down), + new PathFinding.Move(Direction.UpLeft), + new PathFinding.Move(Direction.UpRight), + new PathFinding.Move(Direction.DownLeft), + new PathFinding.Move(Direction.DownRight) + }; + + public static readonly List Bishop = new(4) + { + new PathFinding.Move(Direction.UpLeft, Distance.MultiStep), + new PathFinding.Move(Direction.UpRight, Distance.MultiStep), + new PathFinding.Move(Direction.DownLeft, Distance.MultiStep), + new PathFinding.Move(Direction.DownRight, Distance.MultiStep) + }; + + public static readonly List PromotedBishop = new(8) + { + new PathFinding.Move(Direction.Up), + new PathFinding.Move(Direction.Left), + new PathFinding.Move(Direction.Right), + new PathFinding.Move(Direction.Down), + new PathFinding.Move(Direction.UpLeft, Distance.MultiStep), + new PathFinding.Move(Direction.UpRight, Distance.MultiStep), + new PathFinding.Move(Direction.DownLeft, Distance.MultiStep), + new PathFinding.Move(Direction.DownRight, Distance.MultiStep) + }; + + public static readonly List GoldGeneral = new(6) + { + new PathFinding.Move(Direction.Up), + new PathFinding.Move(Direction.UpLeft), + new PathFinding.Move(Direction.UpRight), + new PathFinding.Move(Direction.Left), + new PathFinding.Move(Direction.Right), + new PathFinding.Move(Direction.Down) + }; + + public static readonly List Knight = new(2) + { + new PathFinding.Move(Direction.KnightLeft), + new PathFinding.Move(Direction.KnightRight) + }; + + public static readonly List Lance = new(1) + { + new PathFinding.Move(Direction.Up, Distance.MultiStep), + }; + + public static readonly List Pawn = new(1) + { + new PathFinding.Move(Direction.Up) + }; + + public static readonly List Rook = new(4) + { + new PathFinding.Move(Direction.Up, Distance.MultiStep), + new PathFinding.Move(Direction.Left, Distance.MultiStep), + new PathFinding.Move(Direction.Right, Distance.MultiStep), + new PathFinding.Move(Direction.Down, Distance.MultiStep) + }; + + public static readonly List PromotedRook = new(8) + { + new PathFinding.Move(Direction.Up, Distance.MultiStep), + new PathFinding.Move(Direction.Left, Distance.MultiStep), + new PathFinding.Move(Direction.Right, Distance.MultiStep), + new PathFinding.Move(Direction.Down, Distance.MultiStep), + new PathFinding.Move(Direction.UpLeft), + new PathFinding.Move(Direction.UpRight), + new PathFinding.Move(Direction.DownLeft), + new PathFinding.Move(Direction.DownRight) + }; + + public static readonly List SilverGeneral = new(4) + { + new PathFinding.Move(Direction.Up), + new PathFinding.Move(Direction.UpLeft), + new PathFinding.Move(Direction.UpRight), + new PathFinding.Move(Direction.DownLeft), + new PathFinding.Move(Direction.DownRight) + }; + } +} diff --git a/Gameboard.ShogiUI.Sockets/Models/Piece.cs b/Gameboard.ShogiUI.Sockets/Models/Piece.cs new file mode 100644 index 0000000..7b86808 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Models/Piece.cs @@ -0,0 +1,70 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; +using PathFinding; +using System.Diagnostics; + +namespace Gameboard.ShogiUI.Sockets.Models +{ + [DebuggerDisplay("{WhichPiece} {Owner}")] + public class Piece : IPlanarElement + { + public WhichPiece WhichPiece { get; } + public WhichPerspective Owner { get; private set; } + public bool IsPromoted { get; private set; } + public bool IsUpsideDown => Owner == WhichPerspective.Player2; + + public Piece(WhichPiece piece, WhichPerspective owner, bool isPromoted = false) + { + WhichPiece = piece; + Owner = owner; + IsPromoted = isPromoted; + } + public Piece(Piece piece) : this(piece.WhichPiece, piece.Owner, piece.IsPromoted) + { + } + + public bool CanPromote => !IsPromoted + && WhichPiece != WhichPiece.King + && WhichPiece != WhichPiece.GoldGeneral; + + public void ToggleOwnership() + { + Owner = Owner == WhichPerspective.Player1 + ? WhichPerspective.Player2 + : WhichPerspective.Player1; + } + + public void Promote() => IsPromoted = CanPromote; + + public void Demote() => IsPromoted = false; + + public void Capture() + { + ToggleOwnership(); + Demote(); + } + + // TODO: There is no reason to make "new" MoveSets every time this property is accessed. + public MoveSet MoveSet => WhichPiece switch + { + WhichPiece.King => new MoveSet(this, MoveSets.King), + WhichPiece.GoldGeneral => new MoveSet(this, MoveSets.GoldGeneral), + WhichPiece.SilverGeneral => new MoveSet(this, IsPromoted ? MoveSets.GoldGeneral : MoveSets.SilverGeneral), + WhichPiece.Bishop => new MoveSet(this, IsPromoted ? MoveSets.PromotedBishop : MoveSets.Bishop), + WhichPiece.Rook => new MoveSet(this, IsPromoted ? MoveSets.PromotedRook : MoveSets.Rook), + WhichPiece.Knight => new MoveSet(this, IsPromoted ? MoveSets.GoldGeneral : MoveSets.Knight), + WhichPiece.Lance => new MoveSet(this, IsPromoted ? MoveSets.GoldGeneral : MoveSets.Lance), + WhichPiece.Pawn => new MoveSet(this, IsPromoted ? MoveSets.GoldGeneral : MoveSets.Pawn), + _ => throw new System.NotImplementedException() + }; + + public ServiceModels.Types.Piece ToServiceModel() + { + return new ServiceModels.Types.Piece + { + IsPromoted = IsPromoted, + Owner = Owner, + WhichPiece = WhichPiece + }; + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Models/Session.cs b/Gameboard.ShogiUI.Sockets/Models/Session.cs new file mode 100644 index 0000000..fd73b40 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Models/Session.cs @@ -0,0 +1,38 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; +using Newtonsoft.Json; +using System.Collections.Concurrent; +using System.Net.WebSockets; + +namespace Gameboard.ShogiUI.Sockets.Models +{ + public class Session + { + // TODO: Separate subscriptions to the Session from the Session. + [JsonIgnore] public ConcurrentDictionary Subscriptions { get; } + public string Name { get; } + public User Player1 { get; } + public User? Player2 { get; private set; } + public bool IsPrivate { get; } + + // TODO: Don't retain the entire rules system within the Session model. It just needs the board state after rules are applied. + public Shogi Shogi { get; } + + public Session(string name, bool isPrivate, Shogi shogi, User player1, User? player2 = null) + { + Subscriptions = new ConcurrentDictionary(); + + Name = name; + Player1 = player1; + Player2 = player2; + IsPrivate = isPrivate; + Shogi = shogi; + } + + public void SetPlayer2(User user) + { + Player2 = user; + } + + public Game ToServiceModel() => new(Name, Player1.DisplayName, Player2?.DisplayName); + } +} diff --git a/Gameboard.ShogiUI.Sockets/Models/SessionMetadata.cs b/Gameboard.ShogiUI.Sockets/Models/SessionMetadata.cs new file mode 100644 index 0000000..350b273 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Models/SessionMetadata.cs @@ -0,0 +1,37 @@ +namespace Gameboard.ShogiUI.Sockets.Models +{ + /// + /// A representation of a Session without the board and game-rules. + /// + public class SessionMetadata + { + public string Name { get; } + public User Player1 { get; } + public User? Player2 { get; private set; } + public bool IsPrivate { get; } + + public SessionMetadata(string name, bool isPrivate, User player1, User? player2 = null) + { + Name = name; + IsPrivate = isPrivate; + Player1 = player1; + Player2 = player2; + } + public SessionMetadata(Session sessionModel) + { + Name = sessionModel.Name; + IsPrivate = sessionModel.IsPrivate; + Player1 = sessionModel.Player1; + Player2 = sessionModel.Player2; + } + + public void SetPlayer2(User user) + { + Player2 = user; + } + + public bool IsSeated(User user) => user.Id == Player1.Id || user.Id == Player2?.Id; + + public ServiceModels.Types.Game ToServiceModel() => new(Name, Player1.DisplayName, Player2?.DisplayName); + } +} diff --git a/Gameboard.ShogiUI.Sockets/Models/Shogi.cs b/Gameboard.ShogiUI.Sockets/Models/Shogi.cs new file mode 100644 index 0000000..ec944a2 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Models/Shogi.cs @@ -0,0 +1,463 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; +using Gameboard.ShogiUI.Sockets.Utilities; +using PathFinding; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace Gameboard.ShogiUI.Sockets.Models +{ + /// + /// Facilitates Shogi board state transitions, cognisant of Shogi rules. + /// The board is always from Player1's perspective. + /// [0,0] is the lower-left position, [8,8] is the higher-right position + /// + public class Shogi + { + private delegate void MoveSetCallback(Piece piece, Vector2 position); + private readonly PathFinder2D pathFinder; + private Shogi? validationBoard; + private Vector2 player1King; + private Vector2 player2King; + private List Hand => WhoseTurn == WhichPerspective.Player1 ? Player1Hand : Player2Hand; + public List Player1Hand { get; } + public List Player2Hand { get; } + public CoordsToNotationCollection Board { get; } //TODO: Hide this being a getter method + public List MoveHistory { get; } + public WhichPerspective WhoseTurn => MoveHistory.Count % 2 == 0 ? WhichPerspective.Player1 : WhichPerspective.Player2; + public WhichPerspective? InCheck { get; private set; } + public bool IsCheckmate { get; private set; } + + public string Error { get; private set; } + + public Shogi() + { + Board = new CoordsToNotationCollection(); + MoveHistory = new List(20); + Player1Hand = new List(); + Player2Hand = new List(); + pathFinder = new PathFinder2D(Board, 9, 9); + player1King = new Vector2(4, 0); + player2King = new Vector2(4, 8); + Error = string.Empty; + + InitializeBoardState(); + } + + public Shogi(IList moves) : this() + { + for (var i = 0; i < moves.Count; i++) + { + if (!Move(moves[i])) + { + // Todo: Add some smarts to know why a move was invalid. In check? Piece not found? etc. + throw new InvalidOperationException($"Unable to construct ShogiBoard with the given move at index {i}. {Error}"); + } + } + } + + private Shogi(Shogi toCopy) + { + Board = new CoordsToNotationCollection(); + foreach (var kvp in toCopy.Board) + { + Board[kvp.Key] = kvp.Value == null ? null : new Piece(kvp.Value); + } + + pathFinder = new PathFinder2D(Board, 9, 9); + MoveHistory = new List(toCopy.MoveHistory); + Player1Hand = new List(toCopy.Player1Hand); + Player2Hand = new List(toCopy.Player2Hand); + player1King = toCopy.player1King; + player2King = toCopy.player2King; + Error = toCopy.Error; + } + + public bool Move(Move move) + { + var otherPlayer = WhoseTurn == WhichPerspective.Player1 ? WhichPerspective.Player2 : WhichPerspective.Player1; + var moveSuccess = TryMove(move); + + if (!moveSuccess) + { + return false; + } + + // Evaluate check + if (EvaluateCheckAfterMove(move, otherPlayer)) + { + InCheck = otherPlayer; + IsCheckmate = EvaluateCheckmate(); + } + else + { + InCheck = null; + } + return true; + } + /// + /// Attempts a given move. Returns false if the move is illegal. + /// + private bool TryMove(Move move) + { + // Try making the move in a "throw away" board. + if (validationBoard == null) + { + validationBoard = new Shogi(this); + } + + var isValid = move.PieceFromHand.HasValue + ? validationBoard.PlaceFromHand(move) + : validationBoard.PlaceFromBoard(move); + if (!isValid) + { + // Surface the error description. + Error = validationBoard.Error; + // Invalidate the "throw away" board. + validationBoard = null; + return false; + } + // If already in check, assert the move that resulted in check no longer results in check. + if (InCheck == WhoseTurn) + { + if (validationBoard.EvaluateCheckAfterMove(MoveHistory[^1], WhoseTurn)) + { + // Sneakily using this.WhoseTurn instead of validationBoard.WhoseTurn; + return false; + } + } + + // The move is valid and legal; update board state. + if (move.PieceFromHand.HasValue) PlaceFromHand(move); + else PlaceFromBoard(move); + return true; + } + /// True if the move was successful. + private bool PlaceFromHand(Move move) + { + var index = Hand.FindIndex(p => p.WhichPiece == move.PieceFromHand); + if (index < 0) + { + Error = $"{move.PieceFromHand} does not exist in the hand."; + return false; + } + if (Board[move.To] != null) + { + Error = $"Illegal move - attempting to capture while playing a piece from the hand."; + return false; + } + + switch (move.PieceFromHand!.Value) + { + case WhichPiece.Knight: + { + // Knight cannot be placed onto the farthest two ranks from the hand. + if ((WhoseTurn == WhichPerspective.Player1 && move.To.Y > 6) + || (WhoseTurn == WhichPerspective.Player2 && move.To.Y < 2)) + { + Error = $"Knight has no valid moves after placed."; + return false; + } + break; + } + case WhichPiece.Lance: + case WhichPiece.Pawn: + { + // Lance and Pawn cannot be placed onto the farthest rank from the hand. + if ((WhoseTurn == WhichPerspective.Player1 && move.To.Y == 8) + || (WhoseTurn == WhichPerspective.Player2 && move.To.Y == 0)) + { + Error = $"{move.PieceFromHand} has no valid moves after placed."; + return false; + } + break; + } + } + + // Mutate the board. + Board[move.To] = Hand[index]; + Hand.RemoveAt(index); + MoveHistory.Add(move); + + return true; + } + /// True if the move was successful. + private bool PlaceFromBoard(Move move) + { + var fromPiece = Board[move.From!.Value]; + if (fromPiece == null) + { + Error = $"No piece exists at {nameof(move)}.{nameof(move.From)}."; + return false; // Invalid move + } + if (fromPiece.Owner != WhoseTurn) + { + Error = "Not allowed to move the opponents piece"; + return false; // Invalid move; cannot move other players pieces. + } + if (IsPathable(move.From.Value, move.To) == false) + { + Error = $"Illegal move for {fromPiece.WhichPiece}. {nameof(move)}.{nameof(move.To)} is not part of the move-set."; + return false; // Invalid move; move not part of move-set. + } + + var captured = Board[move.To]; + if (captured != null) + { + if (captured.Owner == WhoseTurn) return false; // Invalid move; cannot capture your own piece. + captured.Capture(); + Hand.Add(captured); + } + + //Mutate the board. + if (move.IsPromotion) + { + if (WhoseTurn == WhichPerspective.Player1 && (move.To.Y > 5 || move.From.Value.Y > 5)) + { + fromPiece.Promote(); + } + else if (WhoseTurn == WhichPerspective.Player2 && (move.To.Y < 3 || move.From.Value.Y < 3)) + { + fromPiece.Promote(); + } + } + Board[move.To] = fromPiece; + Board[move.From!.Value] = null; + if (fromPiece.WhichPiece == WhichPiece.King) + { + if (fromPiece.Owner == WhichPerspective.Player1) + { + player1King.X = move.To.X; + player1King.Y = move.To.Y; + } + else if (fromPiece.Owner == WhichPerspective.Player2) + { + player2King.X = move.To.X; + player2King.Y = move.To.Y; + } + } + MoveHistory.Add(move); + return true; + } + + private bool IsPathable(Vector2 from, Vector2 to) + { + var piece = Board[from]; + if (piece == null) return false; + + var isObstructed = false; + var isPathable = pathFinder.PathTo(from, to, (other, position) => + { + if (other.Owner == piece.Owner) isObstructed = true; + }); + return !isObstructed && isPathable; + } + + #region Rules Validation + private bool EvaluateCheckAfterMove(Move move, WhichPerspective WhichPerspective) + { + if (WhichPerspective == InCheck) return true; // If we already know the player is in check, don't bother. + + var isCheck = false; + var kingPosition = WhichPerspective == WhichPerspective.Player1 ? player1King : player2King; + + // Check if the move put the king in check. + if (pathFinder.PathTo(move.To, kingPosition)) return true; + + if (move.From.HasValue) + { + // Get line equation from king through the now-unoccupied location. + var direction = Vector2.Subtract(kingPosition, move.From!.Value); + var slope = Math.Abs(direction.Y / direction.X); + // 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)) + { + // if slope of the move is also infinity...can skip this? + pathFinder.LinePathTo(kingPosition, direction, (piece, position) => + { + if (piece.Owner != WhichPerspective) + { + switch (piece.WhichPiece) + { + case WhichPiece.Rook: + isCheck = true; + break; + case WhichPiece.Lance: + if (!piece.IsPromoted) isCheck = true; + break; + } + } + }); + } + else if (slope == 1) + { + pathFinder.LinePathTo(kingPosition, direction, (piece, position) => + { + if (piece.Owner != WhichPerspective && piece.WhichPiece == WhichPiece.Bishop) + { + isCheck = true; + } + }); + } + else if (slope == 0) + { + pathFinder.LinePathTo(kingPosition, direction, (piece, position) => + { + if (piece.Owner != WhichPerspective && piece.WhichPiece == WhichPiece.Rook) + { + isCheck = true; + } + }); + } + } + else + { + // TODO: Check for illegal move from hand. It is illegal to place from the hand such that you check-mate your opponent. + // Go read the shogi rules to be sure this is true. + } + + return isCheck; + } + private bool EvaluateCheckmate() + { + if (!InCheck.HasValue) return false; + + // Assume true and try to disprove. + var isCheckmate = true; + Board.ForEachNotNull((piece, from) => // For each piece... + { + // Short circuit + if (!isCheckmate) return; + + if (piece.Owner == InCheck) // ...owned by the player in check... + { + // ...evaluate if any move gets the player out of check. + pathFinder.PathEvery(from, (other, position) => + { + if (validationBoard == null) validationBoard = new Shogi(this); + var moveToTry = new Move(from, position); + var moveSuccess = validationBoard.TryMove(moveToTry); + if (moveSuccess) + { + validationBoard = null; + if (!EvaluateCheckAfterMove(moveToTry, InCheck.Value)) + { + isCheckmate = false; + } + } + }); + } + }); + return isCheckmate; + } + #endregion + + private void InitializeBoardState() + { + Board["A1"] = new Piece(WhichPiece.Lance, WhichPerspective.Player1); + Board["B1"] = new Piece(WhichPiece.Knight, WhichPerspective.Player1); + Board["C1"] = new Piece(WhichPiece.SilverGeneral, WhichPerspective.Player1); + Board["D1"] = new Piece(WhichPiece.GoldGeneral, WhichPerspective.Player1); + Board["E1"] = new Piece(WhichPiece.King, WhichPerspective.Player1); + Board["F1"] = new Piece(WhichPiece.GoldGeneral, WhichPerspective.Player1); + Board["G1"] = new Piece(WhichPiece.SilverGeneral, WhichPerspective.Player1); + Board["H1"] = new Piece(WhichPiece.Knight, WhichPerspective.Player1); + Board["I1"] = new Piece(WhichPiece.Lance, WhichPerspective.Player1); + + Board["A2"] = null; + Board["B2"] = new Piece(WhichPiece.Bishop, WhichPerspective.Player1); + Board["C2"] = null; + Board["D2"] = null; + Board["E2"] = null; + Board["F2"] = null; + Board["G2"] = null; + Board["H2"] = new Piece(WhichPiece.Rook, WhichPerspective.Player1); + Board["I2"] = null; + + Board["A3"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player1); + Board["B3"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player1); + Board["C3"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player1); + Board["D3"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player1); + Board["E3"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player1); + Board["F3"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player1); + Board["G3"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player1); + Board["H3"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player1); + Board["I3"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player1); + + Board["A4"] = null; + Board["B4"] = null; + Board["C4"] = null; + Board["D4"] = null; + Board["E4"] = null; + Board["F4"] = null; + Board["G4"] = null; + Board["H4"] = null; + Board["I4"] = null; + + Board["A5"] = null; + Board["B5"] = null; + Board["C5"] = null; + Board["D5"] = null; + Board["E5"] = null; + Board["F5"] = null; + Board["G5"] = null; + Board["H5"] = null; + Board["I5"] = null; + + Board["A6"] = null; + Board["B6"] = null; + Board["C6"] = null; + Board["D6"] = null; + Board["E6"] = null; + Board["F6"] = null; + Board["G6"] = null; + Board["H6"] = null; + Board["I6"] = null; + + Board["A7"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player2); + Board["B7"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player2); + Board["C7"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player2); + Board["D7"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player2); + Board["E7"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player2); + Board["F7"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player2); + Board["G7"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player2); + Board["H7"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player2); + Board["I7"] = new Piece(WhichPiece.Pawn, WhichPerspective.Player2); + + Board["A8"] = null; + Board["B8"] = new Piece(WhichPiece.Rook, WhichPerspective.Player2); + Board["C8"] = null; + Board["D8"] = null; + Board["E8"] = null; + Board["F8"] = null; + Board["G8"] = null; + Board["H8"] = new Piece(WhichPiece.Bishop, WhichPerspective.Player2); + Board["I8"] = null; + + Board["A9"] = new Piece(WhichPiece.Lance, WhichPerspective.Player2); + Board["B9"] = new Piece(WhichPiece.Knight, WhichPerspective.Player2); + Board["C9"] = new Piece(WhichPiece.SilverGeneral, WhichPerspective.Player2); + Board["D9"] = new Piece(WhichPiece.GoldGeneral, WhichPerspective.Player2); + Board["E9"] = new Piece(WhichPiece.King, WhichPerspective.Player2); + Board["F9"] = new Piece(WhichPiece.GoldGeneral, WhichPerspective.Player2); + Board["G9"] = new Piece(WhichPiece.SilverGeneral, WhichPerspective.Player2); + Board["H9"] = new Piece(WhichPiece.Knight, WhichPerspective.Player2); + Board["I9"] = new Piece(WhichPiece.Lance, WhichPerspective.Player2); + } + + public BoardState ToServiceModel() + { + return new BoardState + { + Board = Board.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToServiceModel()), + PlayerInCheck = InCheck, + WhoseTurn = WhoseTurn, + Player1Hand = Player1Hand.Select(_ => _.ToServiceModel()).ToList(), + Player2Hand = Player2Hand.Select(_ => _.ToServiceModel()).ToList() + }; + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Models/User.cs b/Gameboard.ShogiUI.Sockets/Models/User.cs new file mode 100644 index 0000000..e633524 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Models/User.cs @@ -0,0 +1,87 @@ +using Gameboard.ShogiUI.Sockets.Repositories.CouchModels; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Security.Claims; + +namespace Gameboard.ShogiUI.Sockets.Models +{ + public class User + { + public static readonly ReadOnlyCollection Adjectives = new(new[] { + "Fortuitous", "Retractable", "Happy", "Habbitable", "Creative", "Fluffy", "Impervious", "Kingly" + }); + public static readonly ReadOnlyCollection Subjects = new(new[] { + "Hippo", "Basil", "Mouse", "Walnut", "Prince", "Lima Bean", "Coala", "Potato", "Penguin" + }); + public static User CreateMsalUser(string id) => new(id, id, WhichLoginPlatform.Microsoft); + public static User CreateGuestUser(string id) + { + var random = new Random(); + // Adjective + var index = (int)Math.Floor(random.NextDouble() * Adjectives.Count); + var adj = Adjectives[index]; + // Subject + index = (int)Math.Floor(random.NextDouble() * Subjects.Count); + var subj = Subjects[index]; + + return new User(id, $"{adj} {subj}", WhichLoginPlatform.Guest); + } + + public string Id { get; } + public string DisplayName { get; } + + public WhichLoginPlatform LoginPlatform { get; } + + public bool IsGuest => LoginPlatform == WhichLoginPlatform.Guest; + + public bool IsAdmin => LoginPlatform == WhichLoginPlatform.Microsoft && Id == "Hauth@live.com"; + + public User(string id, string displayName, WhichLoginPlatform platform) + { + Id = id; + DisplayName = displayName; + LoginPlatform = platform; + } + + public User(UserDocument document) + { + Id = document.Id; + DisplayName = document.DisplayName; + LoginPlatform = document.Platform; + } + + public ClaimsIdentity CreateClaimsIdentity() + { + if (LoginPlatform == WhichLoginPlatform.Guest) + { + var claims = new List(4) + { + new Claim(ClaimTypes.NameIdentifier, Id), + new Claim(ClaimTypes.Name, DisplayName), + new Claim(ClaimTypes.Role, "Guest"), + new Claim(ClaimTypes.Role, "Shogi") // The Shogi role grants access to api controllers. + }; + return new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + } + else + { + var claims = new List(3) + { + new Claim(ClaimTypes.NameIdentifier, Id), + new Claim(ClaimTypes.Name, DisplayName), + new Claim(ClaimTypes.Role, "Shogi") // The Shogi role grants access to api controllers. + }; + return new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme); + } + } + + public ServiceModels.Types.User ToServiceModel() => new() + { + Id = Id, + Name = DisplayName + }; + } +} diff --git a/Gameboard.ShogiUI.Sockets/Models/WhichLoginPlatform.cs b/Gameboard.ShogiUI.Sockets/Models/WhichLoginPlatform.cs new file mode 100644 index 0000000..5d2378e --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Models/WhichLoginPlatform.cs @@ -0,0 +1,9 @@ +namespace Gameboard.ShogiUI.Sockets.Models +{ + public enum WhichLoginPlatform + { + Unknown, + Microsoft, + Guest + } +} diff --git a/Gameboard.ShogiUI.Sockets/Properties/launchSettings.json b/Gameboard.ShogiUI.Sockets/Properties/launchSettings.json index 20f6c84..05ff5bf 100644 --- a/Gameboard.ShogiUI.Sockets/Properties/launchSettings.json +++ b/Gameboard.ShogiUI.Sockets/Properties/launchSettings.json @@ -1,13 +1,13 @@ { + "$schema": "http://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, "anonymousAuthentication": true, "iisExpress": { - "applicationUrl": "http://localhost:63676", - "sslPort": 44396 + "applicationUrl": "http://localhost:50728/", + "sslPort": 44315 } }, - "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { "IIS Express": { "commandName": "IISExpress", @@ -16,13 +16,14 @@ "ASPNETCORE_ENVIRONMENT": "Development" } }, - "AspShogiSockets": { + "Kestrel": { "commandName": "Project", - "launchUrl": "Socket/Token", + "launchBrowser": true, + "launchUrl": "/swagger", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, - "applicationUrl": "http://127.0.0.1:5100" + "applicationUrl": "http://localhost:5100" } } } \ No newline at end of file diff --git a/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/BoardStateDocument.cs b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/BoardStateDocument.cs new file mode 100644 index 0000000..ecd0450 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/BoardStateDocument.cs @@ -0,0 +1,65 @@ +using Gameboard.ShogiUI.Sockets.Utilities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels +{ + public class BoardStateDocument : CouchDocument + { + public string Name { get; set; } + + /// + /// A dictionary where the key is a board-notation position, like D3. + /// + public Dictionary Board { get; set; } + + public Piece[] Player1Hand { get; set; } + + public Piece[] Player2Hand { get; set; } + + /// + /// Move is null for first BoardState of a session - before anybody has made moves. + /// + public Move? Move { get; set; } + + /// + /// Default constructor and setters are for deserialization. + /// + public BoardStateDocument() : base(WhichDocumentType.BoardState) + { + Name = string.Empty; + Board = new Dictionary(81, StringComparer.OrdinalIgnoreCase); + Player1Hand = Array.Empty(); + Player2Hand = Array.Empty(); + } + + public BoardStateDocument(string sessionName, Models.Shogi shogi) + : base($"{sessionName}-{DateTime.Now:O}", WhichDocumentType.BoardState) + { + Name = sessionName; + Board = new Dictionary(81, StringComparer.OrdinalIgnoreCase); + + for (var x = 0; x < 9; x++) + for (var y = 0; y < 9; y++) + { + var position = new Vector2(x, y); + var piece = shogi.Board[y, x]; + + if (piece != null) + { + var positionNotation = NotationHelper.ToBoardNotation(position); + Board[positionNotation] = new Piece(piece); + } + } + + Player1Hand = shogi.Player1Hand.Select(model => new Piece(model)).ToArray(); + Player2Hand = shogi.Player2Hand.Select(model => new Piece(model)).ToArray(); + if (shogi.MoveHistory.Count > 0) + { + Move = new Move(shogi.MoveHistory[^1]); + } + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/CouchCreatedResult.cs b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/CouchCreatedResult.cs new file mode 100644 index 0000000..3435e7b --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/CouchCreatedResult.cs @@ -0,0 +1,15 @@ +namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels +{ + public class CouchCreateResult + { + public string Id { get; set; } + public bool Ok { get; set; } + public string Rev { get; set; } + + public CouchCreateResult() + { + Id = string.Empty; + Rev = string.Empty; + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/CouchDocument.cs b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/CouchDocument.cs new file mode 100644 index 0000000..29d19c3 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/CouchDocument.cs @@ -0,0 +1,26 @@ +using Newtonsoft.Json; +using System; + +namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels +{ + public abstract class CouchDocument + { + [JsonProperty("_id")] public string Id { get; set; } + [JsonProperty("_rev")] public string? RevisionId { get; set; } + public WhichDocumentType DocumentType { get; } + public DateTimeOffset CreatedDate { get; set; } + + public CouchDocument(WhichDocumentType documentType) + : this(string.Empty, documentType, DateTimeOffset.UtcNow) { } + + public CouchDocument(string id, WhichDocumentType documentType) + : this(id, documentType, DateTimeOffset.UtcNow) { } + + public CouchDocument(string id, WhichDocumentType documentType, DateTimeOffset createdDate) + { + Id = id; + DocumentType = documentType; + CreatedDate = createdDate; + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/CouchFindResult.cs b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/CouchFindResult.cs new file mode 100644 index 0000000..daab934 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/CouchFindResult.cs @@ -0,0 +1,16 @@ +using System; + +namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels +{ + internal class CouchFindResult + { + public T[] docs; + public string warning; + + public CouchFindResult() + { + docs = Array.Empty(); + warning = ""; + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/CouchViewResult.cs b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/CouchViewResult.cs new file mode 100644 index 0000000..ca98d2e --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/CouchViewResult.cs @@ -0,0 +1,28 @@ +using System; + +namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels +{ + public class CouchViewResult where T : class + { + public int total_rows; + public int offset; + public CouchViewResultRow[] rows; + + public CouchViewResult() + { + rows = Array.Empty>(); + } + } + + public class CouchViewResultRow + { + public string id; + public T doc; + + public CouchViewResultRow() + { + id = string.Empty; + doc = default!; + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/Move.cs b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/Move.cs new file mode 100644 index 0000000..8b29998 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/Move.cs @@ -0,0 +1,56 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; +using System.Numerics; + +namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels +{ + public class Move + { + /// + /// A board coordinate, like A3 or G6. When null, look for PieceFromHand to exist. + /// + public string? From { get; set; } + + public bool IsPromotion { get; set; } + + /// + /// The piece placed from the player's hand. + /// + public WhichPiece? PieceFromHand { get; set; } + + /// + /// A board coordinate, like A3 or G6. + /// + public string To { get; set; } + + /// + /// Default constructor and setters are for deserialization. + /// + public Move() + { + To = string.Empty; + } + + public Move(Models.Move move) + { + if (move.From.HasValue) + { + From = ToBoardNotation(move.From.Value); + } + IsPromotion = move.IsPromotion; + To = ToBoardNotation(move.To); + PieceFromHand = move.PieceFromHand; + } + + private static readonly char A = 'A'; + private static string ToBoardNotation(Vector2 vector) + { + var file = (char)(vector.X + A); + var rank = vector.Y + 1; + return $"{file}{rank}"; + } + + public Models.Move ToDomainModel() => PieceFromHand.HasValue + ? new(PieceFromHand.Value, To) + : new(From!, To, IsPromotion); + } +} diff --git a/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/Piece.cs b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/Piece.cs new file mode 100644 index 0000000..7f0be6f --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/Piece.cs @@ -0,0 +1,27 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; + +namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels +{ + public class Piece + { + public bool IsPromoted { get; set; } + public WhichPerspective Owner { get; set; } + public WhichPiece WhichPiece { get; set; } + + /// + /// Default constructor and setters are for deserialization. + /// + public Piece() + { + } + + public Piece(Models.Piece piece) + { + IsPromoted = piece.IsPromoted; + Owner = piece.Owner; + WhichPiece = piece.WhichPiece; + } + + public Models.Piece ToDomainModel() => new(WhichPiece, Owner, IsPromoted); + } +} diff --git a/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/SessionDocument.cs b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/SessionDocument.cs new file mode 100644 index 0000000..6db129e --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/SessionDocument.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; + +namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels +{ + public class SessionDocument : CouchDocument + { + public string Name { get; set; } + public string Player1Id { get; set; } + public string? Player2Id { get; set; } + public bool IsPrivate { get; set; } + public IList History { get; set; } + + /// + /// Default constructor and setters are for deserialization. + /// + public SessionDocument() : base(WhichDocumentType.Session) + { + Name = string.Empty; + Player1Id = string.Empty; + Player2Id = string.Empty; + History = new List(0); + } + + public SessionDocument(Models.Session session) + : base(session.Name, WhichDocumentType.Session) + { + Name = session.Name; + Player1Id = session.Player1.Id; + Player2Id = session.Player2?.Id; + IsPrivate = session.IsPrivate; + History = new List(0); + } + + public SessionDocument(Models.SessionMetadata sessionMetaData) + : base(sessionMetaData.Name, WhichDocumentType.Session) + { + Name = sessionMetaData.Name; + Player1Id = sessionMetaData.Player1.Id; + Player2Id = sessionMetaData.Player2?.Id; + IsPrivate = sessionMetaData.IsPrivate; + History = new List(0); + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/UserDocument.cs b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/UserDocument.cs new file mode 100644 index 0000000..940a8bd --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/UserDocument.cs @@ -0,0 +1,27 @@ +using Gameboard.ShogiUI.Sockets.Models; + +namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels +{ + public class UserDocument : CouchDocument + { + public string DisplayName { get; set; } + public WhichLoginPlatform Platform { get; set; } + + /// + /// Constructor for JSON deserializing. + /// + public UserDocument() : base(WhichDocumentType.User) + { + DisplayName = string.Empty; + } + + public UserDocument( + string id, + string displayName, + WhichLoginPlatform platform) : base(id, WhichDocumentType.User) + { + DisplayName = displayName; + Platform = platform; + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/WhichDocumentType.cs b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/WhichDocumentType.cs new file mode 100644 index 0000000..f5a4f72 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/WhichDocumentType.cs @@ -0,0 +1,9 @@ +namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels +{ + public enum WhichDocumentType + { + User, + Session, + BoardState + } +} diff --git a/Gameboard.ShogiUI.Sockets/Repositories/GameboardRepository.cs b/Gameboard.ShogiUI.Sockets/Repositories/GameboardRepository.cs index 07504fc..3489693 100644 --- a/Gameboard.ShogiUI.Sockets/Repositories/GameboardRepository.cs +++ b/Gameboard.ShogiUI.Sockets/Repositories/GameboardRepository.cs @@ -1,128 +1,292 @@ -using Gameboard.Shogi.Api.ServiceModels.Messages; -using Gameboard.ShogiUI.Sockets.Repositories.Utility; +using Gameboard.ShogiUI.Sockets.Extensions; +using Gameboard.ShogiUI.Sockets.Repositories.CouchModels; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; using System.Net.Http; using System.Text; using System.Threading.Tasks; +using System.Web; namespace Gameboard.ShogiUI.Sockets.Repositories { public interface IGameboardRepository { - Task DeleteGame(string gameName); - Task GetGame(string gameName); - Task GetGames(); - Task GetGames(string playerName); - Task GetMoves(string gameName); - Task PostSession(PostSession request); - Task PostJoinPrivateSession(PostJoinPrivateSession request); - Task PutJoinPublicSession(string gameName, PutJoinPublicSession request); - Task PostMove(string gameName, PostMove request); - Task PostJoinCode(string gameName, string userName); - Task GetPlayer(string userName); - Task PostPlayer(PostPlayer request); + Task CreateBoardState(Models.Session session); + Task CreateSession(Models.SessionMetadata session); + Task CreateUser(Models.User user); + Task> ReadSessionMetadatas(); + Task ReadSession(string name); + Task UpdateSession(Models.SessionMetadata session); + Task ReadSessionMetaData(string name); + Task ReadUser(string userName); } public class GameboardRepository : IGameboardRepository { - private readonly IAuthenticatedHttpClient client; - public GameboardRepository(IAuthenticatedHttpClient client) + /// + /// Returns session, board state, and user documents, grouped by session. + /// + private static readonly string View_SessionWithBoardState = "_design/session/_view/session-with-boardstate"; + /// + /// Returns session and user documents, grouped by session. + /// + private static readonly string View_SessionMetadata = "_design/session/_view/session-metadata"; + private static readonly string View_User = "_design/user/_view/user"; + private const string ApplicationJson = "application/json"; + private readonly HttpClient client; + private readonly ILogger logger; + + public GameboardRepository(IHttpClientFactory clientFactory, ILogger logger) { - this.client = client; + client = clientFactory.CreateClient("couchdb"); + this.logger = logger; } - public async Task GetGames() + public async Task> ReadSessionMetadatas() { - var response = await client.GetAsync("Sessions"); - var json = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(json); + var queryParams = new QueryBuilder { { "include_docs", "true" } }.ToQueryString(); + var response = await client.GetAsync($"{View_SessionMetadata}{queryParams}"); + var responseContent = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseContent); + if (result != null) + { + var groupedBySession = result.rows.GroupBy(row => row.id); + var sessions = new List(result.total_rows / 3); + foreach (var group in groupedBySession) + { + /** + * A group contains 3 elements. + * 1) The session metadata. + * 2) User document of Player1. + * 3) User document of Player2. + */ + var session = group.FirstOrDefault()?.doc.ToObject(); + var player1Doc = group.Skip(1).FirstOrDefault()?.doc.ToObject(); + var player2Doc = group.Skip(2).FirstOrDefault()?.doc.ToObject(); + if (session != null && player1Doc != null) + { + var player2 = player2Doc == null ? null : new Models.User(player2Doc); + sessions.Add(new Models.SessionMetadata(session.Name, session.IsPrivate, new(player1Doc), player2)); + } + } + return new Collection(sessions); + } + return new Collection(Array.Empty()); } - public async Task GetGames(string playerName) + public async Task ReadSession(string name) { - var uri = $"Sessions/{playerName}"; - var response = await client.GetAsync(Uri.EscapeUriString(uri)); - var json = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(json); + var queryParams = new QueryBuilder + { + { "include_docs", "true" }, + { "startkey", JsonConvert.SerializeObject(new [] {name}) }, + { "endkey", JsonConvert.SerializeObject(new object [] {name, int.MaxValue}) } + }.ToQueryString(); + var query = $"{View_SessionWithBoardState}{queryParams}"; + logger.LogInformation("ReadSession() query: {query}", query); + var response = await client.GetAsync(query); + var responseContent = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseContent); + if (result != null && result.rows.Length > 2) + { + var group = result.rows; + /** + * A group contains 3 type of elements. + * 1) The session metadata. + * 2) User documents of Player1 and Player2. + * 2.a) If the Player2 document doesn't exist, CouchDB will return the SessionDocument instead :( + * 3) BoardState + */ + var session = group[0].doc.ToObject(); + var player1Doc = group[1].doc.ToObject(); + var group2DocumentType = group[2].doc.Property(nameof(UserDocument.DocumentType).ToCamelCase())?.Value.Value(); + var player2Doc = group2DocumentType == WhichDocumentType.User.ToString() + ? group[2].doc.ToObject() + : null; + var moves = group + .Skip(4) // Skip 4 because group[3] will not have a .Move property since it's the first/initial BoardState of the session. + // TODO: Deserialize just the Move property. + .Select(row => row.doc.ToObject()) + .Select(boardState => + { + var move = boardState!.Move!; + return move.PieceFromHand.HasValue + ? new Models.Move(move.PieceFromHand.Value, move.To) + : new Models.Move(move.From!, move.To, move.IsPromotion); + }) + .ToList(); + + var shogi = new Models.Shogi(moves); + if (session != null && player1Doc != null) + { + var player2 = player2Doc == null ? null : new Models.User(player2Doc); + return new Models.Session(session.Name, session.IsPrivate, shogi, new(player1Doc), player2); + } + } + return null; } - public async Task GetGame(string gameName) + public async Task ReadSessionMetaData(string name) { - var uri = $"Session/{gameName}"; - var response = await client.GetAsync(Uri.EscapeUriString(uri)); - var json = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(json); + var queryParams = new QueryBuilder + { + { "include_docs", "true" }, + { "startkey", JsonConvert.SerializeObject(new [] {name}) }, + { "endkey", JsonConvert.SerializeObject(new object [] {name, int.MaxValue}) } + }.ToQueryString(); + var response = await client.GetAsync($"{View_SessionMetadata}{queryParams}"); + var responseContent = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseContent); + if (result != null && result.rows.Length > 2) + { + var group = result.rows; + /** + * A group contains 3 elements. + * 1) The session metadata. + * 2) User document of Player1. + * 3) User document of Player2. + */ + var session = group[0].doc.ToObject(); + var player1Doc = group[1].doc.ToObject(); + var group2DocumentType = group[2].doc.Property(nameof(UserDocument.DocumentType).ToCamelCase())?.Value.Value(); + var player2Doc = group2DocumentType == WhichDocumentType.User.ToString() + ? group[2].doc.ToObject() + : null; + if (session != null && player1Doc != null) + { + var player2 = player2Doc == null ? null : new Models.User(player2Doc); + return new Models.SessionMetadata(session.Name, session.IsPrivate, new(player1Doc), player2); + } + } + return null; } - public async Task DeleteGame(string gameName) + /// + /// Saves a snapshot of board state and the most recent move. + /// + public async Task CreateBoardState(Models.Session session) { - var uri = $"Session/{gameName}"; - await client.DeleteAsync(Uri.EscapeUriString(uri)); + var boardStateDocument = new BoardStateDocument(session.Name, session.Shogi); + var content = new StringContent(JsonConvert.SerializeObject(boardStateDocument), Encoding.UTF8, ApplicationJson); + var response = await client.PostAsync(string.Empty, content); + return response.IsSuccessStatusCode; } - public async Task PostSession(PostSession request) + public async Task CreateSession(Models.SessionMetadata session) { - var content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json"); - var response = await client.PostAsync("Session", content); - var json = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(json); + var sessionDocument = new SessionDocument(session); + var sessionContent = new StringContent(JsonConvert.SerializeObject(sessionDocument), Encoding.UTF8, ApplicationJson); + var postSessionDocumentTask = client.PostAsync(string.Empty, sessionContent); + + var boardStateDocument = new BoardStateDocument(session.Name, new Models.Shogi()); + var boardStateContent = new StringContent(JsonConvert.SerializeObject(boardStateDocument), Encoding.UTF8, ApplicationJson); + + if ((await postSessionDocumentTask).IsSuccessStatusCode) + { + var response = await client.PostAsync(string.Empty, boardStateContent); + return response.IsSuccessStatusCode; + } + return false; } - public async Task PutJoinPublicSession(string gameName, PutJoinPublicSession request) + public async Task UpdateSession(Models.SessionMetadata session) { - var uri = $"Session/Join"; - var content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json"); - var response = await client.PutAsync(Uri.EscapeUriString(uri), content); - var json = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(json); + // GET existing session to get revisionId. + var readResponse = await client.GetAsync(session.Name); + if (!readResponse.IsSuccessStatusCode) return false; + var sessionDocument = JsonConvert.DeserializeObject(await readResponse.Content.ReadAsStringAsync()); + + // PUT the document with the revisionId. + var couchModel = new SessionDocument(session) + { + RevisionId = sessionDocument?.RevisionId + }; + var content = new StringContent(JsonConvert.SerializeObject(couchModel), Encoding.UTF8, ApplicationJson); + var response = await client.PutAsync(couchModel.Id, content); + return response.IsSuccessStatusCode; + } + //public async Task PutJoinPublicSession(PutJoinPublicSession request) + //{ + // var content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, MediaType); + // var response = await client.PutAsync(JoinSessionRoute, content); + // var json = await response.Content.ReadAsStringAsync(); + // return JsonConvert.DeserializeObject(json).JoinSucceeded; + //} + + //public async Task PostJoinPrivateSession(PostJoinPrivateSession request) + //{ + // var content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, MediaType); + // var response = await client.PostAsync(JoinSessionRoute, content); + // var json = await response.Content.ReadAsStringAsync(); + // var deserialized = JsonConvert.DeserializeObject(json); + // if (deserialized.JoinSucceeded) + // { + // return deserialized.SessionName; + // } + // return null; + //} + + //public async Task> GetMoves(string gameName) + //{ + // var uri = $"Session/{gameName}/Moves"; + // var get = await client.GetAsync(Uri.EscapeUriString(uri)); + // var json = await get.Content.ReadAsStringAsync(); + // if (string.IsNullOrWhiteSpace(json)) + // { + // return new List(); + // } + // var response = JsonConvert.DeserializeObject(json); + // return response.Moves.Select(m => new Move(m)).ToList(); + //} + + //public async Task PostMove(string gameName, PostMove request) + //{ + // var uri = $"Session/{gameName}/Move"; + // var content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, MediaType); + // await client.PostAsync(Uri.EscapeUriString(uri), content); + //} + + public async Task PostJoinCode(string gameName, string userName) + { + // var uri = $"JoinCode/{gameName}"; + // var serialized = JsonConvert.SerializeObject(new PostJoinCode { PlayerName = userName }); + // var content = new StringContent(serialized, Encoding.UTF8, MediaType); + // var json = await (await client.PostAsync(Uri.EscapeUriString(uri), content)).Content.ReadAsStringAsync(); + // return JsonConvert.DeserializeObject(json).JoinCode; + return string.Empty; } - public async Task PostJoinPrivateSession(PostJoinPrivateSession request) + public async Task ReadUser(string id) { - var content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json"); - var response = await client.PostAsync("Session/Join", content); - var json = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(json); + var queryParams = new QueryBuilder + { + { "include_docs", "true" }, + { "key", JsonConvert.SerializeObject(id) }, + }.ToQueryString(); + var response = await client.GetAsync($"{View_User}{queryParams}"); + var responseContent = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseContent); + if (result != null && result.rows.Length > 0) + { + return new Models.User(result.rows[0].doc); + } + + return null; } - public async Task GetMoves(string gameName) + public async Task CreateUser(Models.User user) { - var uri = $"Session/{gameName}/Moves"; - var response = await client.GetAsync(Uri.EscapeUriString(uri)); - var json = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(json); + var couchModel = new UserDocument(user.Id, user.DisplayName, user.LoginPlatform); + var content = new StringContent(JsonConvert.SerializeObject(couchModel), Encoding.UTF8, ApplicationJson); + var response = await client.PostAsync(string.Empty, content); + return response.IsSuccessStatusCode; } - public async Task PostMove(string gameName, PostMove request) - { - var uri = $"Session/{gameName}/Move"; - var content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json"); - await client.PostAsync(Uri.EscapeUriString(uri), content); - } - - public async Task PostJoinCode(string gameName, string userName) - { - var uri = $"JoinCode/{gameName}"; - var serialized = JsonConvert.SerializeObject(new PostJoinCode { PlayerName = userName }); - var content = new StringContent(serialized, Encoding.UTF8, "application/json"); - var json = await (await client.PostAsync(Uri.EscapeUriString(uri), content)).Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(json); - } - - public async Task GetPlayer(string playerName) - { - var uri = $"Player/{playerName}"; - var response = await client.GetAsync(Uri.EscapeUriString(uri)); - Console.WriteLine(response); - var json = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(json); - } - - public async Task PostPlayer(PostPlayer request) - { - var content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json"); - return await client.PostAsync("Player", content); - } } } diff --git a/Gameboard.ShogiUI.Sockets/Repositories/RepositoryManagers/GameboardRepositoryManager.cs b/Gameboard.ShogiUI.Sockets/Repositories/RepositoryManagers/GameboardRepositoryManager.cs deleted file mode 100644 index 55ff15a..0000000 --- a/Gameboard.ShogiUI.Sockets/Repositories/RepositoryManagers/GameboardRepositoryManager.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Gameboard.Shogi.Api.ServiceModels.Messages; -using System; -using System.Threading.Tasks; - -namespace Gameboard.ShogiUI.Sockets.Repositories.RepositoryManagers -{ - public interface IGameboardRepositoryManager - { - Task CreateGuestUser(); - Task IsPlayer1(string sessionName, string playerName); - bool IsGuest(string playerName); - } - - public class GameboardRepositoryManager : IGameboardRepositoryManager - { - private const int MaxTries = 3; - private const string GuestPrefix = "Guest-"; - private readonly IGameboardRepository repository; - - public GameboardRepositoryManager(IGameboardRepository repository) - { - this.repository = repository; - } - - public async Task CreateGuestUser() - { - var count = 0; - while (count < MaxTries) - { - count++; - var clientId = $"Guest-{Guid.NewGuid()}"; - var request = new PostPlayer - { - PlayerName = clientId - }; - var response = await repository.PostPlayer(request); - if (response.IsSuccessStatusCode) - { - return clientId; - } - } - throw new OperationCanceledException($"Failed to create guest user after {MaxTries} tries."); - } - - public async Task IsPlayer1(string sessionName, string playerName) - { - var session = await repository.GetGame(sessionName); - return session?.Session.Player1 == playerName; - } - - public async Task CreateJoinCode(string sessionName, string playerName) - { - var getGameResponse = await repository.GetGame(sessionName); - if (playerName == getGameResponse?.Session.Player1) - { - return (await repository.PostJoinCode(sessionName, playerName)).JoinCode; - } - return null; - } - - public bool IsGuest(string playerName) => playerName.StartsWith(GuestPrefix); - } -} diff --git a/Gameboard.ShogiUI.Sockets/Repositories/Utility/AuthenticatedHttpClient.cs b/Gameboard.ShogiUI.Sockets/Repositories/Utility/AuthenticatedHttpClient.cs deleted file mode 100644 index 07a3df4..0000000 --- a/Gameboard.ShogiUI.Sockets/Repositories/Utility/AuthenticatedHttpClient.cs +++ /dev/null @@ -1,120 +0,0 @@ -using IdentityModel.Client; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; - -namespace Gameboard.ShogiUI.Sockets.Repositories.Utility -{ - public interface IAuthenticatedHttpClient - { - Task DeleteAsync(string requestUri); - Task GetAsync(string requestUri); - Task PostAsync(string requestUri, HttpContent content); - Task PutAsync(string requestUri, HttpContent content); - } - - public class AuthenticatedHttpClient : HttpClient, IAuthenticatedHttpClient - { - private readonly ILogger logger; - private readonly string identityServerUrl; - private TokenResponse tokenResponse; - private readonly string clientId; - private readonly string clientSecret; - - public AuthenticatedHttpClient(ILogger logger, IConfiguration configuration) : base() - { - this.logger = logger; - identityServerUrl = configuration["AppSettings:IdentityServer"]; - clientId = configuration["AppSettings:ClientId"]; - clientSecret = configuration["AppSettings:ClientSecret"]; - BaseAddress = new Uri(configuration["AppSettings:GameboardShogiApi"]); - } - private async Task RefreshBearerToken() - { - var disco = await this.GetDiscoveryDocumentAsync(identityServerUrl); - if (disco.IsError) - { - logger.LogError("{DiscoveryErrorType}", disco.ErrorType); - throw new Exception(disco.Error); - } - - var request = new ClientCredentialsTokenRequest - { - Address = disco.TokenEndpoint, - ClientId = clientId, - ClientSecret = clientSecret - }; - var response = await this.RequestClientCredentialsTokenAsync(request); - if (response.IsError) - { - throw new Exception(response.Error); - } - tokenResponse = response; - logger.LogInformation("Refreshing Bearer Token to {BaseAddress}", BaseAddress); - this.SetBearerToken(tokenResponse.AccessToken); - } - public async new Task PutAsync(string requestUri, HttpContent content) - { - var response = await base.PutAsync(requestUri, content); - if (response.StatusCode == HttpStatusCode.Unauthorized) - { - await RefreshBearerToken(); - response = await base.PostAsync(requestUri, content); - } - logger.LogInformation( - "Repository PUT to {BaseUrl}{RequestUrl} \n\tRespCode: {RespCode} \n\tRequest: {Request}\n\tResponse: {Response}\n", - BaseAddress, - requestUri, - response.StatusCode, - await content.ReadAsStringAsync(), - await response.Content.ReadAsStringAsync()); - return response; - } - public async new Task GetAsync(string requestUri) - { - var response = await base.GetAsync(requestUri); - if (response.StatusCode == HttpStatusCode.Unauthorized) - { - await RefreshBearerToken(); - response = await base.GetAsync(requestUri); - } - logger.LogInformation( - "Repository GET to {BaseUrl}{RequestUrl} \nResponse: {Response}\n", - BaseAddress, - requestUri, - await response.Content.ReadAsStringAsync()); - return response; - } - public async new Task PostAsync(string requestUri, HttpContent content) - { - var response = await base.PostAsync(requestUri, content); - if (response.StatusCode == HttpStatusCode.Unauthorized) - { - await RefreshBearerToken(); - response = await base.PostAsync(requestUri, content); - } - logger.LogInformation( - "Repository POST to {BaseUrl}{RequestUrl} \n\tRespCode: {RespCode} \n\tRequest: {Request}\n\tResponse: {Response}\n", - BaseAddress, - requestUri, - response.StatusCode, - await content.ReadAsStringAsync(), - await response.Content.ReadAsStringAsync()); - return response; - } - public async new Task DeleteAsync(string requestUri) - { - var response = await base.DeleteAsync(requestUri); - if (response.StatusCode == HttpStatusCode.Unauthorized) - { - await RefreshBearerToken(); - response = await base.DeleteAsync(requestUri); - } - logger.LogInformation("Repository DELETE to {BaseUrl}{RequestUrl}", BaseAddress, requestUri); - return response; - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Services/RequestValidators/JoinByCodeRequestValidator.cs b/Gameboard.ShogiUI.Sockets/Services/RequestValidators/JoinByCodeRequestValidator.cs new file mode 100644 index 0000000..e1de3a2 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Services/RequestValidators/JoinByCodeRequestValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; + +namespace Gameboard.ShogiUI.Sockets.Services.RequestValidators +{ + public class JoinByCodeRequestValidator : AbstractValidator + { + public JoinByCodeRequestValidator() + { + RuleFor(_ => _.Action).Equal(ClientAction.JoinByCode); + RuleFor(_ => _.JoinCode).NotEmpty(); + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Services/RequestValidators/JoinGameRequestValidator.cs b/Gameboard.ShogiUI.Sockets/Services/RequestValidators/JoinGameRequestValidator.cs new file mode 100644 index 0000000..da598e3 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Services/RequestValidators/JoinGameRequestValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; + +namespace Gameboard.ShogiUI.Sockets.Services.RequestValidators +{ + public class JoinGameRequestValidator : AbstractValidator + { + public JoinGameRequestValidator() + { + RuleFor(_ => _.Action).Equal(ClientAction.JoinGame); + RuleFor(_ => _.GameName).NotEmpty(); + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Services/SocketService.cs b/Gameboard.ShogiUI.Sockets/Services/SocketService.cs new file mode 100644 index 0000000..0418950 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Services/SocketService.cs @@ -0,0 +1,132 @@ +using FluentValidation; +using Gameboard.ShogiUI.Sockets.Extensions; +using Gameboard.ShogiUI.Sockets.Managers; +using Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers; +using Gameboard.ShogiUI.Sockets.Repositories; +using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; +using Gameboard.ShogiUI.Sockets.Services.Utility; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System; +using System.Linq; +using System.Net; +using System.Net.WebSockets; +using System.Threading.Tasks; + +namespace Gameboard.ShogiUI.Sockets.Services +{ + public interface ISocketService + { + Task HandleSocketRequest(HttpContext context); + } + + /// + /// Services a single websocket connection. Authenticates the socket connection, accepts messages, and sends messages. + /// + public class SocketService : ISocketService + { + private readonly ILogger logger; + private readonly ISocketConnectionManager communicationManager; + private readonly IGameboardRepository gameboardRepository; + private readonly IGameboardManager gameboardManager; + private readonly ISocketTokenCache tokenManager; + private readonly IJoinByCodeHandler joinByCodeHandler; + private readonly IValidator joinByCodeRequestValidator; + private readonly IValidator joinGameRequestValidator; + + public SocketService( + ILogger logger, + ISocketConnectionManager communicationManager, + IGameboardRepository gameboardRepository, + IGameboardManager gameboardManager, + ISocketTokenCache tokenManager, + IJoinByCodeHandler joinByCodeHandler, + IValidator joinByCodeRequestValidator, + IValidator joinGameRequestValidator + ) : base() + { + this.logger = logger; + this.communicationManager = communicationManager; + this.gameboardRepository = gameboardRepository; + this.gameboardManager = gameboardManager; + this.tokenManager = tokenManager; + this.joinByCodeHandler = joinByCodeHandler; + this.joinByCodeRequestValidator = joinByCodeRequestValidator; + this.joinGameRequestValidator = joinGameRequestValidator; + } + + public async Task HandleSocketRequest(HttpContext context) + { + if (!context.Request.Query.Keys.Contains("token")) + { + context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + return; + } + var token = Guid.Parse(context.Request.Query["token"][0]); + var userName = tokenManager.GetUsername(token); + + if (string.IsNullOrEmpty(userName)) + { + context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + return; + } + var socket = await context.WebSockets.AcceptWebSocketAsync(); + + communicationManager.SubscribeToBroadcast(socket, userName); + while (socket.State == WebSocketState.Open) + { + try + { + var message = await socket.ReceiveTextAsync(); + if (string.IsNullOrWhiteSpace(message)) continue; + logger.LogInformation("Request \n{0}\n", message); + var request = JsonConvert.DeserializeObject(message); + if (request == null || !Enum.IsDefined(typeof(ClientAction), request.Action)) + { + await socket.SendTextAsync("Error: Action not recognized."); + continue; + } + switch (request.Action) + { + case ClientAction.JoinByCode: + { + var req = JsonConvert.DeserializeObject(message); + if (req != null && await ValidateRequestAndReplyIfInvalid(socket, joinByCodeRequestValidator, req)) + { + await joinByCodeHandler.Handle(req, userName); + } + break; + } + default: + await socket.SendTextAsync($"Received your message with action {request.Action}, but did no work."); + break; + } + } + catch (OperationCanceledException ex) + { + logger.LogError(ex.Message); + } + catch (WebSocketException ex) + { + logger.LogInformation($"{nameof(WebSocketException)} in {nameof(SocketConnectionManager)}."); + logger.LogInformation("Probably tried writing to a closed socket."); + logger.LogError(ex.Message); + } + communicationManager.UnsubscribeFromBroadcastAndGames(userName); + } + } + + public async Task ValidateRequestAndReplyIfInvalid(WebSocket socket, IValidator validator, TRequest request) + { + var results = validator.Validate(request); + if (!results.IsValid) + { + var errors = string.Join('\n', results.Errors.Select(_ => _.ErrorMessage)); + await socket.SendTextAsync(errors); + } + return results.IsValid; + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Managers/Utility/JsonRequest.cs b/Gameboard.ShogiUI.Sockets/Services/Utility/JsonRequest.cs similarity index 66% rename from Gameboard.ShogiUI.Sockets/Managers/Utility/JsonRequest.cs rename to Gameboard.ShogiUI.Sockets/Services/Utility/JsonRequest.cs index 9ac96f6..c4fde65 100644 --- a/Gameboard.ShogiUI.Sockets/Managers/Utility/JsonRequest.cs +++ b/Gameboard.ShogiUI.Sockets/Services/Utility/JsonRequest.cs @@ -1,6 +1,6 @@ -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Interfaces; +using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; -namespace Gameboard.ShogiUI.Sockets.Managers.Utility +namespace Gameboard.ShogiUI.Sockets.Services.Utility { public class JsonRequest { diff --git a/Gameboard.ShogiUI.Sockets/Services/Utility/Request.cs b/Gameboard.ShogiUI.Sockets/Services/Utility/Request.cs new file mode 100644 index 0000000..1928408 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Services/Utility/Request.cs @@ -0,0 +1,10 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; + +namespace Gameboard.ShogiUI.Sockets.Services.Utility +{ + public class Request : IRequest + { + public ClientAction Action { get; set; } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Services/Utility/Response.cs b/Gameboard.ShogiUI.Sockets/Services/Utility/Response.cs new file mode 100644 index 0000000..067d374 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Services/Utility/Response.cs @@ -0,0 +1,9 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; + +namespace Gameboard.ShogiUI.Sockets.Services.Utility +{ + public class Response : IResponse + { + public string Action { get; set; } + } +} diff --git a/Gameboard.ShogiUI.Sockets/ShogiUserClaimsTransformer.cs b/Gameboard.ShogiUI.Sockets/ShogiUserClaimsTransformer.cs new file mode 100644 index 0000000..fc00288 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/ShogiUserClaimsTransformer.cs @@ -0,0 +1,43 @@ +using Gameboard.ShogiUI.Sockets.Repositories; +using Microsoft.AspNetCore.Authentication; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace Gameboard.ShogiUI.Sockets +{ + /// + /// Standardizes the claims from third party issuers. Also registers new msal users in the database. + /// + public class ShogiUserClaimsTransformer : IClaimsTransformation + { + private static readonly string MsalUsernameClaim = "preferred_username"; + private readonly IGameboardRepository gameboardRepository; + + public ShogiUserClaimsTransformer(IGameboardRepository gameboardRepository) + { + this.gameboardRepository = gameboardRepository; + } + + public async Task TransformAsync(ClaimsPrincipal principal) + { + var nameClaim = principal.Claims.FirstOrDefault(c => c.Type == MsalUsernameClaim); + if (nameClaim != default) + { + var user = await gameboardRepository.ReadUser(nameClaim.Value); + if (user == null) + { + var newUser = Models.User.CreateMsalUser(nameClaim.Value); + var success = await gameboardRepository.CreateUser(newUser); + if (success) user = newUser; + } + + if (user != null) + { + return new ClaimsPrincipal(user.CreateClaimsIdentity()); + } + } + return principal; + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Startup.cs b/Gameboard.ShogiUI.Sockets/Startup.cs index 7d32044..c735d0e 100644 --- a/Gameboard.ShogiUI.Sockets/Startup.cs +++ b/Gameboard.ShogiUI.Sockets/Startup.cs @@ -1,149 +1,217 @@ -using Gameboard.ShogiUI.Sockets.Repositories.RepositoryManagers; +using FluentValidation; using Gameboard.ShogiUI.Sockets.Extensions; +using Gameboard.ShogiUI.Sockets.Managers; +using Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers; +using Gameboard.ShogiUI.Sockets.Repositories; +using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; +using Gameboard.ShogiUI.Sockets.Services; +using Gameboard.ShogiUI.Sockets.Services.RequestValidators; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Http; +using Microsoft.Identity.Client; +using Microsoft.Identity.Web; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Serialization; using System; using System.Collections.Generic; using System.Linq; -using Gameboard.ShogiUI.Sockets.Managers; -using Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers; -using Gameboard.ShogiUI.Sockets.Repositories; -using Gameboard.ShogiUI.Sockets.Repositories.Utility; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; namespace Gameboard.ShogiUI.Sockets { - public class Startup - { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } - public IConfiguration Configuration { get; } + public IConfiguration Configuration { get; } - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - // Socket ActionHandlers - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton, JoinByCodeRequestValidator>(); + services.AddSingleton, JoinGameRequestValidator>(); + services.AddSingleton(); + services.AddTransient(); + services.AddSingleton(); + services.AddHttpClient("couchdb", c => + { + var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:admin")); + c.DefaultRequestHeaders.Add("Accept", "application/json"); + c.DefaultRequestHeaders.Add("Authorization", $"Basic {base64}"); - // Managers - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddScoped(); - services.AddSingleton(sp => action => - { - return action switch - { - ClientAction.ListGames => sp.GetService(), - ClientAction.CreateGame => sp.GetService(), - ClientAction.JoinGame => sp.GetService(), - ClientAction.JoinByCode => sp.GetService(), - ClientAction.LoadGame => sp.GetService(), - ClientAction.Move => sp.GetService(), - _ => throw new KeyNotFoundException($"Unable to resolve {nameof(IActionHandler)} for {nameof(ClientAction)} {action}"), - }; - }); + var baseUrl = $"{Configuration["AppSettings:CouchDB:Url"]}/{Configuration["AppSettings:CouchDB:Database"]}/"; + c.BaseAddress = new Uri(baseUrl); + }); - // Repositories - services.AddTransient(); - services.AddSingleton(); + services + .AddControllers() + .AddNewtonsoftJson(options => + { + options.SerializerSettings.Formatting = Formatting.Indented; + options.SerializerSettings.ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy { ProcessDictionaryKeys = true } + }; + options.SerializerSettings.Converters = new[] { new StringEnumConverter() }; + options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; + }); - services.AddControllers(); + services.AddAuthentication("CookieOrJwt") + .AddPolicyScheme("CookieOrJwt", "Either cookie or jwt", options => + { + options.ForwardDefaultSelector = context => + { + var bearerAuth = context.Request.Headers["Authorization"].FirstOrDefault()?.StartsWith("Bearer ") ?? false; + return bearerAuth + ? JwtBearerDefaults.AuthenticationScheme + : CookieAuthenticationDefaults.AuthenticationScheme; + }; + }) + .AddCookie(options => + { + options.Cookie.Name = "session-id"; + options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.None; + options.Cookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always; + options.SlidingExpiration = true; + }) + .AddMicrosoftIdentityWebApi(Configuration); - services - .AddAuthentication(options => - { - options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - }) - .AddJwtBearer(options => - { - options.Authority = "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0"; - options.Audience = "935df672-efa6-45fa-b2e8-b76dfd65a122"; - options.TokenValidationParameters.ValidateIssuer = true; - options.TokenValidationParameters.ValidateAudience = true; - }); - } + services.AddSwaggerDocument(config => + { + //config.AddSecurity("bearer", Enumerable.Empty(), new NSwag.OpenApiSecurityScheme + //{ + // Type = NSwag.OpenApiSecuritySchemeType.OAuth2, + // Flow = NSwag.OpenApiOAuth2Flow.Implicit, + // Flows = new NSwag.OpenApiOAuthFlows + // { + // Implicit = new NSwag.OpenApiOAuthFlow + // { + // Scopes = + // } + // } + //}); - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ISocketConnectionManager socketConnectionManager) - { - var origins = new[] { - "http://localhost:3000", "https://localhost:3000", - "http://127.0.0.1:3000", "https://127.0.0.1:3000", - "https://dev.lucaserver.space", "https://lucaserver.space" - }; - var socketOptions = new WebSocketOptions(); - foreach (var o in origins) - socketOptions.AllowedOrigins.Add(o); + // This just ensures anyone with a microsoft account can make API calls. + config.AddSecurity("bearer", new NSwag.OpenApiSecurityScheme + { + Type = NSwag.OpenApiSecuritySchemeType.OAuth2, + Flow = NSwag.OpenApiOAuth2Flow.Implicit, + AuthorizationUrl = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + TokenUrl = "https://login.microsoftonline.com/common/oauth2/v2.0/token", + Scopes = new Dictionary { + { "api://c1e94676-cab0-42ba-8b6c-9532b8486fff/access_as_user", "The scope" }, + { "api://c1e94676-cab0-42ba-8b6c-9532b8486fff/ShogiAdmin", "Admin scope" } + }, + Scheme = "bearer", + BearerFormat = "JWT", + In = NSwag.OpenApiSecurityApiKeyLocation.Header, + }); + config.PostProcess = document => + { + document.Info.Title = "Gameboard.ShogiUI.Sockets"; + }; + }); - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - else - { - app.UseHsts(); - } - app - .UseRequestResponseLogging() - .UseCors( - opt => opt - .WithOrigins(origins) - .AllowAnyMethod() - .AllowAnyHeader() - .AllowCredentials() - ) - .UseRouting() - .UseAuthentication() - .UseAuthorization() - .UseWebSockets(socketOptions) - .UseEndpoints(endpoints => - { - endpoints.MapControllers(); - }) - .Use(async (context, next) => - { - var isUpgradeHeader = context - .Request - .Headers - .Any(h => h.Key.Contains("upgrade", StringComparison.InvariantCultureIgnoreCase) - && h.Value.ToString().Contains("websocket", StringComparison.InvariantCultureIgnoreCase)); - if (isUpgradeHeader) - { - await socketConnectionManager.HandleSocketRequest(context); - } - else - { - await next(); - } - }); + // Remove default HttpClient logging. + services.RemoveAll(); + } - JsonConvert.DefaultSettings = () => new JsonSerializerSettings - { - Formatting = Formatting.Indented, - ContractResolver = new DefaultContractResolver - { - NamingStrategy = new CamelCaseNamingStrategy(), - }, - Converters = new[] { new StringEnumConverter() }, - NullValueHandling = NullValueHandling.Ignore - }; - } - } + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ISocketService socketConnectionManager) + { + var origins = new[] { + "http://localhost:3000", "https://localhost:3000", + "http://127.0.0.1:3000", "https://127.0.0.1:3000", + "https://dev.lucaserver.space", "https://lucaserver.space" + }; + var socketOptions = new WebSocketOptions(); + foreach (var o in origins) + socketOptions.AllowedOrigins.Add(o); + + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + var client = PublicClientApplicationBuilder + .Create(Configuration["AzureAd:ClientId"]) + .WithLogging( + (level, message, pii) => + { + Console.WriteLine(message); + }, + LogLevel.Verbose, + true, + true + ) + .Build(); + } + else + { + app.UseHsts(); + } + app + .UseRequestResponseLogging() + .UseCors(opt => opt.WithOrigins(origins).AllowAnyMethod().AllowAnyHeader().WithExposedHeaders("Set-Cookie").AllowCredentials()) + .UseRouting() + .UseAuthentication() + .UseAuthorization() + .UseOpenApi() + .UseSwaggerUi3(config => + { + config.OAuth2Client = new NSwag.AspNetCore.OAuth2ClientSettings() + { + ClientId = "c1e94676-cab0-42ba-8b6c-9532b8486fff", + UsePkceWithAuthorizationCodeGrant = true + }; + //config.WithCredentials = true; + }) + .UseWebSockets(socketOptions) + .UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }) + .Use(async (context, next) => + { + if (context.WebSockets.IsWebSocketRequest) + { + await socketConnectionManager.HandleSocketRequest(context); + } + else + { + await next(); + } + }); + + JsonConvert.DefaultSettings = () => new JsonSerializerSettings + { + Formatting = Formatting.Indented, + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy + { + ProcessDictionaryKeys = true + } + }, + Converters = new[] { new StringEnumConverter() }, + NullValueHandling = NullValueHandling.Ignore, + }; + } + } } diff --git a/Gameboard.ShogiUI.Sockets/Utilities/CoordsToNotationCollection.cs b/Gameboard.ShogiUI.Sockets/Utilities/CoordsToNotationCollection.cs new file mode 100644 index 0000000..c302164 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Utilities/CoordsToNotationCollection.cs @@ -0,0 +1,48 @@ +using Gameboard.ShogiUI.Sockets.Models; +using PathFinding; +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace Gameboard.ShogiUI.Sockets.Utilities +{ + public class CoordsToNotationCollection : Dictionary, IPlanarCollection + { + public delegate void ForEachDelegate(Piece element, Vector2 position); + + public CoordsToNotationCollection() : base(81, StringComparer.OrdinalIgnoreCase) + { + } + + public CoordsToNotationCollection(Dictionary board) : base(board, StringComparer.OrdinalIgnoreCase) + { + } + + public Piece? this[Vector2 vector] + { + get => this[NotationHelper.ToBoardNotation(vector)]; + set => this[NotationHelper.ToBoardNotation(vector)] = value; + } + + public Piece? this[int x, int y] + { + get => this[NotationHelper.ToBoardNotation(x, y)]; + set => this[NotationHelper.ToBoardNotation(x, y)] = value; + } + + + public void ForEachNotNull(ForEachDelegate callback) + { + for (var x = 0; x < 9; x++) + { + for (var y = 0; y < 9; y++) + { + var position = new Vector2(x, y); + var elem = this[position]; + if (elem != null) + callback(elem, position); + } + } + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Utilities/NotationHelper.cs b/Gameboard.ShogiUI.Sockets/Utilities/NotationHelper.cs new file mode 100644 index 0000000..c6831db --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Utilities/NotationHelper.cs @@ -0,0 +1,36 @@ +using System; +using System.Numerics; +using System.Text.RegularExpressions; + +namespace Gameboard.ShogiUI.Sockets.Utilities +{ + public static class NotationHelper + { + private static readonly string BoardNotationRegex = @"(?[a-iA-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) + { + notation = notation.ToUpper(); + if (Regex.IsMatch(notation, BoardNotationRegex)) + { + var match = Regex.Match(notation, BoardNotationRegex); + 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/Gameboard.ShogiUI.Sockets/appsettings.json b/Gameboard.ShogiUI.Sockets/appsettings.json index 1b50ee6..119c533 100644 --- a/Gameboard.ShogiUI.Sockets/appsettings.json +++ b/Gameboard.ShogiUI.Sockets/appsettings.json @@ -1,17 +1,23 @@ { "AppSettings": { - "IdentityServer": "https://identity.lucaserver.space/", - "GameboardShogiApi": "https://dev.lucaserver.space/Gameboard.Shogi.Api/", - "ClientId": "DevClientId", - "ClientSecret": "DevSecret", - "Scope": "DevEnvironment" + "CouchDB": { + "Database": "shogi-dev", + "Url": "http://192.168.1.15:5984" + } }, "Logging": { "LogLevel": { - "Default": "Information", + "Default": "Warning", "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" + "Microsoft.Hosting.Lifetime": "Error" } }, + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "ClientId": "c1e94676-cab0-42ba-8b6c-9532b8486fff", + "TenantId": "common", + "Audience": "c1e94676-cab0-42ba-8b6c-9532b8486fff", + "ClientSecret": "" + }, "AllowedHosts": "*" } \ No newline at end of file diff --git a/Gameboard.ShogiUI.UnitTests/Gameboard.ShogiUI.UnitTests.csproj b/Gameboard.ShogiUI.UnitTests/Gameboard.ShogiUI.UnitTests.csproj new file mode 100644 index 0000000..16999c2 --- /dev/null +++ b/Gameboard.ShogiUI.UnitTests/Gameboard.ShogiUI.UnitTests.csproj @@ -0,0 +1,21 @@ + + + + net6.0 + + + + + + + + + + + + + + + + + diff --git a/Gameboard.ShogiUI.UnitTests/PathFinding/PathFinder2DShould.cs b/Gameboard.ShogiUI.UnitTests/PathFinding/PathFinder2DShould.cs new file mode 100644 index 0000000..983ec6f --- /dev/null +++ b/Gameboard.ShogiUI.UnitTests/PathFinding/PathFinder2DShould.cs @@ -0,0 +1,36 @@ +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PathFinding; +using System.Numerics; + +namespace Gameboard.ShogiUI.UnitTests.PathFinding +{ + [TestClass] + public class PathFinder2DShould + { + [TestMethod] + public void Maths() + { + var result = PathFinder2D.IsPathable( + new Vector2(2, 2), + new Vector2(7, 7), + new Vector2(1, 1) + ); + result.Should().BeTrue(); + + result = PathFinder2D.IsPathable( + new Vector2(2, 2), + new Vector2(7, 7), + new Vector2(0, 0) + ); + result.Should().BeFalse(); + + result = PathFinder2D.IsPathable( + new Vector2(2, 2), + new Vector2(7, 7), + new Vector2(-1, 1) + ); + result.Should().BeFalse(); + } + } +} diff --git a/Gameboard.ShogiUI.UnitTests/PathFinding/PlanarCollectionShould.cs b/Gameboard.ShogiUI.UnitTests/PathFinding/PlanarCollectionShould.cs new file mode 100644 index 0000000..2ac90d6 --- /dev/null +++ b/Gameboard.ShogiUI.UnitTests/PathFinding/PlanarCollectionShould.cs @@ -0,0 +1,57 @@ +using AutoFixture; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; + +namespace Gameboard.ShogiUI.UnitTests.PathFinding +{ + [TestClass] + public class PlanarCollectionShould + { + [TestMethod] + public void Index() + { + // Arrange + var collection = new TestPlanarCollection(); + var expected1 = new SimpleElement(1); + var expected2 = new SimpleElement(2); + + // Act + collection[0, 0] = expected1; + collection[2, 1] = expected2; + + // Assert + collection[0, 0].Should().Be(expected1); + collection[2, 1].Should().Be(expected2); + } + + [TestMethod] + public void Iterate() + { + // Arrange + var planarCollection = new TestPlanarCollection(); + planarCollection[0, 0] = new SimpleElement(1); + planarCollection[0, 1] = new SimpleElement(2); + planarCollection[0, 2] = new SimpleElement(3); + planarCollection[1, 0] = new SimpleElement(4); + planarCollection[1, 1] = new SimpleElement(5); + + // Act + var actual = new List(); + foreach (var elem in planarCollection) + actual.Add(elem); + + // Assert + using (new AssertionScope()) + { + actual[0].Number.Should().Be(1); + actual[1].Number.Should().Be(2); + actual[2].Number.Should().Be(3); + actual[3].Number.Should().Be(4); + actual[4].Number.Should().Be(5); + } + } + } +} diff --git a/Gameboard.ShogiUI.UnitTests/PathFinding/TestPlanarCollection.cs b/Gameboard.ShogiUI.UnitTests/PathFinding/TestPlanarCollection.cs new file mode 100644 index 0000000..4cb3a74 --- /dev/null +++ b/Gameboard.ShogiUI.UnitTests/PathFinding/TestPlanarCollection.cs @@ -0,0 +1,48 @@ +using PathFinding; +using System.Collections; +using System.Collections.Generic; +using System.Numerics; + +namespace Gameboard.ShogiUI.UnitTests.PathFinding +{ + public class SimpleElement : IPlanarElement + { + public int Number { get; } + public MoveSet MoveSet => null; + public bool IsUpsideDown => false; + + public SimpleElement(int number) + { + Number = number; + } + } + + public class TestPlanarCollection : IPlanarCollection + { + private readonly SimpleElement[,] array; + public TestPlanarCollection() + { + array = new SimpleElement[3, 3]; + } + public SimpleElement this[int x, int y] + { + get => array[x, y]; + set => array[x, y] = value; + } + public SimpleElement this[Vector2 vector] + { + get => this[(int)vector.X, (int)vector.Y]; + set => this[(int)vector.X, (int)vector.Y] = value; + } + + public IEnumerator GetEnumerator() + { + foreach (var e in array) + yield return e; + } + //IEnumerator IEnumerable.GetEnumerator() + //{ + // return array.GetEnumerator(); + //} + } +} diff --git a/Gameboard.ShogiUI.UnitTests/Rules/ShogiBoardShould.cs b/Gameboard.ShogiUI.UnitTests/Rules/ShogiBoardShould.cs new file mode 100644 index 0000000..1476dbb --- /dev/null +++ b/Gameboard.ShogiUI.UnitTests/Rules/ShogiBoardShould.cs @@ -0,0 +1,15 @@ +using FluentAssertions; +using Gameboard.ShogiUI.Sockets.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Linq; +using System.Numerics; +using WhichPerspective = Gameboard.ShogiUI.Sockets.ServiceModels.Types.WhichPerspective; +using WhichPiece = Gameboard.ShogiUI.Sockets.ServiceModels.Types.WhichPiece; +namespace Gameboard.ShogiUI.UnitTests.Rules +{ + [TestClass] + public class ShogiBoardShould + { + + } +} diff --git a/Gameboard.ShogiUI.xUnitTests/CoordsToNotationCollectionShould.cs b/Gameboard.ShogiUI.xUnitTests/CoordsToNotationCollectionShould.cs new file mode 100644 index 0000000..d7c0123 --- /dev/null +++ b/Gameboard.ShogiUI.xUnitTests/CoordsToNotationCollectionShould.cs @@ -0,0 +1,41 @@ +using AutoFixture; +using FluentAssertions; +using Gameboard.ShogiUI.Sockets.Models; +using Gameboard.ShogiUI.Sockets.Utilities; +using Xunit; + +namespace Gameboard.ShogiUI.xUnitTests +{ + public class CoordsToNotationCollectionShould + { + private readonly Fixture fixture; + private readonly CoordsToNotationCollection collection; + public CoordsToNotationCollectionShould() + { + fixture = new Fixture(); + collection = new CoordsToNotationCollection(); + } + + [Fact] + public void TranslateCoordinatesToNotation() + { + // Arrange + collection[0, 0] = fixture.Create(); + collection[4, 4] = fixture.Create(); + collection[8, 8] = fixture.Create(); + collection[2, 2] = fixture.Create(); + + // Assert + collection["A1"].Should().BeSameAs(collection[0, 0]); + collection["E5"].Should().BeSameAs(collection[4, 4]); + collection["I9"].Should().BeSameAs(collection[8, 8]); + collection["C3"].Should().BeSameAs(collection[2, 2]); + } + + [Fact] + public void Yep() + { + + } + } +} diff --git a/Gameboard.ShogiUI.xUnitTests/GameShould.cs b/Gameboard.ShogiUI.xUnitTests/GameShould.cs new file mode 100644 index 0000000..7d9354e --- /dev/null +++ b/Gameboard.ShogiUI.xUnitTests/GameShould.cs @@ -0,0 +1,17 @@ +using FluentAssertions; +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; +using Xunit; + +namespace Gameboard.ShogiUI.xUnitTests +{ + public class GameShould + { + [Fact] + public void DiscardNullPLayers() + { + var game = new Game("Test", "P1", null); + + game.Players.Count.Should().Be(1); + } + } +} diff --git a/Gameboard.ShogiUI.xUnitTests/Gameboard.ShogiUI.xUnitTests.csproj b/Gameboard.ShogiUI.xUnitTests/Gameboard.ShogiUI.xUnitTests.csproj new file mode 100644 index 0000000..1de7d82 --- /dev/null +++ b/Gameboard.ShogiUI.xUnitTests/Gameboard.ShogiUI.xUnitTests.csproj @@ -0,0 +1,34 @@ + + + + net6.0 + + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + Always + + + + diff --git a/Gameboard.ShogiUI.xUnitTests/NotationHelperShould.cs b/Gameboard.ShogiUI.xUnitTests/NotationHelperShould.cs new file mode 100644 index 0000000..45c4020 --- /dev/null +++ b/Gameboard.ShogiUI.xUnitTests/NotationHelperShould.cs @@ -0,0 +1,22 @@ +using FluentAssertions; +using Gameboard.ShogiUI.Sockets.Utilities; +using System.Numerics; +using Xunit; + +namespace Gameboard.ShogiUI.xUnitTests +{ + public class NotationHelperShould + { + [Fact] + public void TranslateVectorsToNotation() + { + NotationHelper.ToBoardNotation(2, 2).Should().Be("C3"); + } + + [Fact] + public void TranslateNotationToVectors() + { + NotationHelper.FromBoardNotation("C3").Should().Be(new Vector2(2, 2)); + } + } +} diff --git a/Gameboard.ShogiUI.xUnitTests/ShogiShould.cs b/Gameboard.ShogiUI.xUnitTests/ShogiShould.cs new file mode 100644 index 0000000..9c565f6 --- /dev/null +++ b/Gameboard.ShogiUI.xUnitTests/ShogiShould.cs @@ -0,0 +1,620 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Gameboard.ShogiUI.Sockets.Extensions; +using Gameboard.ShogiUI.Sockets.Models; +using System.Linq; +using Xunit; +using Xunit.Abstractions; +using WhichPiece = Gameboard.ShogiUI.Sockets.ServiceModels.Types.WhichPiece; +using WhichPerspective = Gameboard.ShogiUI.Sockets.ServiceModels.Types.WhichPerspective; + +namespace Gameboard.ShogiUI.xUnitTests +{ + public class ShogiShould + { + private readonly ITestOutputHelper output; + public ShogiShould(ITestOutputHelper output) + { + this.output = output; + } + + [Fact] + public void InitializeBoardState() + { + // Act + var board = new Shogi().Board; + + // Assert + board["A1"].WhichPiece.Should().Be(WhichPiece.Lance); + board["A1"].Owner.Should().Be(WhichPerspective.Player1); + board["A1"].IsPromoted.Should().Be(false); + board["B1"].WhichPiece.Should().Be(WhichPiece.Knight); + board["B1"].Owner.Should().Be(WhichPerspective.Player1); + board["B1"].IsPromoted.Should().Be(false); + board["C1"].WhichPiece.Should().Be(WhichPiece.SilverGeneral); + board["C1"].Owner.Should().Be(WhichPerspective.Player1); + board["C1"].IsPromoted.Should().Be(false); + board["D1"].WhichPiece.Should().Be(WhichPiece.GoldGeneral); + board["D1"].Owner.Should().Be(WhichPerspective.Player1); + board["D1"].IsPromoted.Should().Be(false); + board["E1"].WhichPiece.Should().Be(WhichPiece.King); + board["E1"].Owner.Should().Be(WhichPerspective.Player1); + board["E1"].IsPromoted.Should().Be(false); + board["F1"].WhichPiece.Should().Be(WhichPiece.GoldGeneral); + board["F1"].Owner.Should().Be(WhichPerspective.Player1); + board["F1"].IsPromoted.Should().Be(false); + board["G1"].WhichPiece.Should().Be(WhichPiece.SilverGeneral); + board["G1"].Owner.Should().Be(WhichPerspective.Player1); + board["G1"].IsPromoted.Should().Be(false); + board["H1"].WhichPiece.Should().Be(WhichPiece.Knight); + board["H1"].Owner.Should().Be(WhichPerspective.Player1); + board["H1"].IsPromoted.Should().Be(false); + board["I1"].WhichPiece.Should().Be(WhichPiece.Lance); + board["I1"].Owner.Should().Be(WhichPerspective.Player1); + board["I1"].IsPromoted.Should().Be(false); + + board["A2"].Should().BeNull(); + board["B2"].WhichPiece.Should().Be(WhichPiece.Bishop); + board["B2"].Owner.Should().Be(WhichPerspective.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(WhichPerspective.Player1); + board["H2"].IsPromoted.Should().Be(false); + board["I2"].Should().BeNull(); + + board["A3"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["A3"].Owner.Should().Be(WhichPerspective.Player1); + board["A3"].IsPromoted.Should().Be(false); + board["B3"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["B3"].Owner.Should().Be(WhichPerspective.Player1); + board["B3"].IsPromoted.Should().Be(false); + board["C3"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["C3"].Owner.Should().Be(WhichPerspective.Player1); + board["C3"].IsPromoted.Should().Be(false); + board["D3"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["D3"].Owner.Should().Be(WhichPerspective.Player1); + board["D3"].IsPromoted.Should().Be(false); + board["E3"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["E3"].Owner.Should().Be(WhichPerspective.Player1); + board["E3"].IsPromoted.Should().Be(false); + board["F3"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["F3"].Owner.Should().Be(WhichPerspective.Player1); + board["F3"].IsPromoted.Should().Be(false); + board["G3"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["G3"].Owner.Should().Be(WhichPerspective.Player1); + board["G3"].IsPromoted.Should().Be(false); + board["H3"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["H3"].Owner.Should().Be(WhichPerspective.Player1); + board["H3"].IsPromoted.Should().Be(false); + board["I3"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["I3"].Owner.Should().Be(WhichPerspective.Player1); + board["I3"].IsPromoted.Should().Be(false); + + 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(); + + 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(); + + 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(); + + board["A7"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["A7"].Owner.Should().Be(WhichPerspective.Player2); + board["A7"].IsPromoted.Should().Be(false); + board["B7"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["B7"].Owner.Should().Be(WhichPerspective.Player2); + board["B7"].IsPromoted.Should().Be(false); + board["C7"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["C7"].Owner.Should().Be(WhichPerspective.Player2); + board["C7"].IsPromoted.Should().Be(false); + board["D7"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["D7"].Owner.Should().Be(WhichPerspective.Player2); + board["D7"].IsPromoted.Should().Be(false); + board["E7"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["E7"].Owner.Should().Be(WhichPerspective.Player2); + board["E7"].IsPromoted.Should().Be(false); + board["F7"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["F7"].Owner.Should().Be(WhichPerspective.Player2); + board["F7"].IsPromoted.Should().Be(false); + board["G7"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["G7"].Owner.Should().Be(WhichPerspective.Player2); + board["G7"].IsPromoted.Should().Be(false); + board["H7"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["H7"].Owner.Should().Be(WhichPerspective.Player2); + board["H7"].IsPromoted.Should().Be(false); + board["I7"].WhichPiece.Should().Be(WhichPiece.Pawn); + board["I7"].Owner.Should().Be(WhichPerspective.Player2); + board["I7"].IsPromoted.Should().Be(false); + + board["A8"].Should().BeNull(); + board["B8"].WhichPiece.Should().Be(WhichPiece.Rook); + board["B8"].Owner.Should().Be(WhichPerspective.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(WhichPerspective.Player2); + board["H8"].IsPromoted.Should().Be(false); + board["I8"].Should().BeNull(); + + board["A9"].WhichPiece.Should().Be(WhichPiece.Lance); + board["A9"].Owner.Should().Be(WhichPerspective.Player2); + board["A9"].IsPromoted.Should().Be(false); + board["B9"].WhichPiece.Should().Be(WhichPiece.Knight); + board["B9"].Owner.Should().Be(WhichPerspective.Player2); + board["B9"].IsPromoted.Should().Be(false); + board["C9"].WhichPiece.Should().Be(WhichPiece.SilverGeneral); + board["C9"].Owner.Should().Be(WhichPerspective.Player2); + board["C9"].IsPromoted.Should().Be(false); + board["D9"].WhichPiece.Should().Be(WhichPiece.GoldGeneral); + board["D9"].Owner.Should().Be(WhichPerspective.Player2); + board["D9"].IsPromoted.Should().Be(false); + board["E9"].WhichPiece.Should().Be(WhichPiece.King); + board["E9"].Owner.Should().Be(WhichPerspective.Player2); + board["E9"].IsPromoted.Should().Be(false); + board["F9"].WhichPiece.Should().Be(WhichPiece.GoldGeneral); + board["F9"].Owner.Should().Be(WhichPerspective.Player2); + board["F9"].IsPromoted.Should().Be(false); + board["G9"].WhichPiece.Should().Be(WhichPiece.SilverGeneral); + board["G9"].Owner.Should().Be(WhichPerspective.Player2); + board["G9"].IsPromoted.Should().Be(false); + board["H9"].WhichPiece.Should().Be(WhichPiece.Knight); + board["H9"].Owner.Should().Be(WhichPerspective.Player2); + board["H9"].IsPromoted.Should().Be(false); + board["I9"].WhichPiece.Should().Be(WhichPiece.Lance); + board["I9"].Owner.Should().Be(WhichPerspective.Player2); + board["I9"].IsPromoted.Should().Be(false); + } + + [Fact] + public void InitializeBoardStateWithMoves() + { + var moves = new[] + { + // P1 Pawn + new Move("A3", "A4") + }; + var shogi = new Shogi(moves); + shogi.Board["A3"].Should().BeNull(); + shogi.Board["A4"].WhichPiece.Should().Be(WhichPiece.Pawn); + } + + [Fact] + public void AllowValidMoves_AfterCheck() + { + // Arrange + var moves = new[] + { + // P1 Pawn + new Move("C3", "C4"), + // P2 Pawn + new Move("G7", "G6"), + // P1 Bishop puts P2 in check + new Move("B2", "G7"), + }; + var shogi = new Shogi(moves); + shogi.InCheck.Should().Be(WhichPerspective.Player2); + + // Act - P2 is able to un-check theirself. + /// P2 King moves out of check + var moveSuccess = shogi.Move(new Move("E9", "E8")); + + // Assert + using var _ = new AssertionScope(); + moveSuccess.Should().BeTrue(); + shogi.InCheck.Should().BeNull(); + } + + [Fact] + public void PreventInvalidMoves_MoveFromEmptyPosition() + { + // Arrange + var shogi = new Shogi(); + shogi.Board["D5"].Should().BeNull(); + + // Act + var moveSuccess = shogi.Move(new Move("D5", "D6")); + + // Assert + moveSuccess.Should().BeFalse(); + shogi.Board["D5"].Should().BeNull(); + shogi.Board["D6"].Should().BeNull(); + } + + [Fact] + public void PreventInvalidMoves_MoveToCurrentPosition() + { + // Arrange + var shogi = new Shogi(); + + // Act - P1 "moves" pawn to the position it already exists at. + var moveSuccess = shogi.Move(new Move("A3", "A3")); + + // Assert + moveSuccess.Should().BeFalse(); + shogi.Board["A3"].WhichPiece.Should().Be(WhichPiece.Pawn); + shogi.Player1Hand.Should().BeEmpty(); + shogi.Player2Hand.Should().BeEmpty(); + } + + [Fact] + public void PreventInvalidMoves_MoveSet() + { + // Arrange + var shogi = new Shogi(); + + // Act - Move Lance illegally + var moveSuccess = shogi.Move(new Move("A1", "D5")); + + // Assert + moveSuccess.Should().BeFalse(); + shogi.Board["A1"].WhichPiece.Should().Be(WhichPiece.Lance); + shogi.Board["A5"].Should().BeNull(); + shogi.Player1Hand.Should().BeEmpty(); + shogi.Player2Hand.Should().BeEmpty(); + } + + [Fact] + public void PreventInvalidMoves_Ownership() + { + // Arrange + var shogi = new Shogi(); + shogi.WhoseTurn.Should().Be(WhichPerspective.Player1); + shogi.Board["A7"].Owner.Should().Be(WhichPerspective.Player2); + + // Act - Move Player2 Pawn when it is Player1 turn. + var moveSuccess = shogi.Move(new Move("A7", "A6")); + + // Assert + moveSuccess.Should().BeFalse(); + shogi.Board["A7"].WhichPiece.Should().Be(WhichPiece.Pawn); + shogi.Board["A6"].Should().BeNull(); + } + + [Fact] + public void PreventInvalidMoves_MoveThroughAllies() + { + // Arrange + var shogi = new Shogi(); + + // Act - Move P1 Lance through P1 Pawn. + var moveSuccess = shogi.Move(new Move("A1", "A5")); + + // Assert + moveSuccess.Should().BeFalse(); + shogi.Board["A1"].WhichPiece.Should().Be(WhichPiece.Lance); + shogi.Board["A3"].WhichPiece.Should().Be(WhichPiece.Pawn); + shogi.Board["A5"].Should().BeNull(); + } + + [Fact] + public void PreventInvalidMoves_CaptureAlly() + { + // Arrange + var shogi = new Shogi(); + + // Act - P1 Knight tries to capture P1 Pawn. + var moveSuccess = shogi.Move(new Move("B1", "C3")); + + // Arrange + moveSuccess.Should().BeFalse(); + shogi.Board["B1"].WhichPiece.Should().Be(WhichPiece.Knight); + shogi.Board["C3"].WhichPiece.Should().Be(WhichPiece.Pawn); + shogi.Player1Hand.Should().BeEmpty(); + shogi.Player2Hand.Should().BeEmpty(); + } + + [Fact] + public void PreventInvalidMoves_Check() + { + // Arrange + var moves = new[] + { + // P1 Pawn + new Move("C3", "C4"), + // P2 Pawn + new Move("G7", "G6"), + // P1 Bishop puts P2 in check + new Move("B2", "G7") + }; + var shogi = new Shogi(moves); + shogi.InCheck.Should().Be(WhichPerspective.Player2); + + // Act - P2 moves Lance while in check. + var moveSuccess = shogi.Move(new Move("I9", "I8")); + + // Assert + moveSuccess.Should().BeFalse(); + shogi.InCheck.Should().Be(WhichPerspective.Player2); + shogi.Board["I9"].WhichPiece.Should().Be(WhichPiece.Lance); + shogi.Board["I8"].Should().BeNull(); + } + + [Fact] + public void PreventInvalidDrops_MoveSet() + { + // Arrange + var moves = new[] + { + // P1 Pawn + new Move("C3", "C4"), + // P2 Pawn + new Move("I7", "I6"), + // P1 Bishop takes P2 Pawn. + new Move("B2", "G7"), + // P2 Gold, block check from P1 Bishop. + new Move("F9", "F8"), + // P1 Bishop takes P2 Bishop, promotes so it can capture P2 Knight and P2 Lance + new Move("G7", "H8", true), + // P2 Pawn again + new Move("I6", "I5"), + // P1 Bishop takes P2 Knight + new Move("H8", "H9"), + // P2 Pawn again + new Move("I5", "I4"), + // P1 Bishop takes P2 Lance + new Move("H9", "I9"), + // P2 Pawn captures P1 Pawn + new Move("I4", "I3") + }; + var shogi = new Shogi(moves); + shogi.Player1Hand.Count.Should().Be(4); + shogi.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Knight); + shogi.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Lance); + shogi.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Pawn); + shogi.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop); + shogi.WhoseTurn.Should().Be(WhichPerspective.Player1); + + // Act | Assert - Illegally placing Knight from the hand in farthest row. + shogi.Board["H9"].Should().BeNull(); + var dropSuccess = shogi.Move(new Move(WhichPiece.Knight, "H9")); + dropSuccess.Should().BeFalse(); + shogi.Board["H9"].Should().BeNull(); + shogi.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Knight); + + // Act | Assert - Illegally placing Knight from the hand in second farthest row. + shogi.Board["H8"].Should().BeNull(); + dropSuccess = shogi.Move(new Move(WhichPiece.Knight, "H8")); + dropSuccess.Should().BeFalse(); + shogi.Board["H8"].Should().BeNull(); + shogi.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Knight); + + // Act | Assert - Illegally place Lance from the hand. + shogi.Board["H9"].Should().BeNull(); + dropSuccess = shogi.Move(new Move(WhichPiece.Knight, "H9")); + dropSuccess.Should().BeFalse(); + shogi.Board["H9"].Should().BeNull(); + shogi.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Lance); + + // Act | Assert - Illegally place Pawn from the hand. + shogi.Board["H9"].Should().BeNull(); + dropSuccess = shogi.Move(new Move(WhichPiece.Pawn, "H9")); + dropSuccess.Should().BeFalse(); + shogi.Board["H9"].Should().BeNull(); + shogi.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 moves = new[] + { + // P1 Pawn + new Move("C3", "C4"), + // P2 Pawn + new Move("G7", "G6"), + // P1 Pawn, arbitrary move. + new Move("A3", "A4"), + // P2 Bishop takes P1 Bishop + new Move("H8", "B2"), + // P1 Silver takes P2 Bishop + new Move("C1", "B2"), + // P2 Pawn, arbtrary move + new Move("A7", "A6"), + // P1 drop Bishop, place P2 in check + new Move(WhichPiece.Bishop, "G7") + }; + var shogi = new Shogi(moves); + shogi.InCheck.Should().Be(WhichPerspective.Player2); + shogi.Player2Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop); + shogi.Board["E5"].Should().BeNull(); + + // Act - P2 places a Bishop while in check. + var dropSuccess = shogi.Move(new Move(WhichPiece.Bishop, "E5")); + + // Assert + dropSuccess.Should().BeFalse(); + shogi.Board["E5"].Should().BeNull(); + shogi.InCheck.Should().Be(WhichPerspective.Player2); + shogi.Player2Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop); + } + + [Fact] + public void PreventInvalidDrop_Capture() + { + // Arrange + var moves = new[] + { + // P1 Pawn + new Move("C3", "C4"), + // P2 Pawn + new Move("G7", "G6"), + // P1 Bishop capture P2 Bishop + new Move("B2", "H8"), + // P2 Pawn + new Move("G6", "G5") + }; + var shogi = new Shogi(moves); + using (new AssertionScope()) + { + shogi.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop); + shogi.Board["I9"].Should().NotBeNull(); + shogi.Board["I9"].WhichPiece.Should().Be(WhichPiece.Lance); + shogi.Board["I9"].Owner.Should().Be(WhichPerspective.Player2); + } + + // Act - P1 tries to place a piece where an opponent's piece resides. + var dropSuccess = shogi.Move(new Move(WhichPiece.Bishop, "I9")); + + // Assert + using (new AssertionScope()) + { + dropSuccess.Should().BeFalse(); + shogi.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop); + shogi.Board["I9"].Should().NotBeNull(); + shogi.Board["I9"].WhichPiece.Should().Be(WhichPiece.Lance); + shogi.Board["I9"].Owner.Should().Be(WhichPerspective.Player2); + } + } + + [Fact] + public void Check() + { + // Arrange + var moves = new[] + { + // P1 Pawn + new Move("C3", "C4"), + // P2 Pawn + new Move("G7", "G6"), + }; + var shogi = new Shogi(moves); + + // Act - P1 Bishop, check + shogi.Move(new Move("B2", "G7")); + + // Assert + shogi.InCheck.Should().Be(WhichPerspective.Player2); + } + + [Fact] + public void Promote() + { + // Arrange + var moves = new[] + { + // P1 Pawn + new Move("C3", "C4" ), + // P2 Pawn + new Move("G7", "G6" ) + }; + var shogi = new Shogi(moves); + + // Act - P1 moves across promote threshold. + var moveSuccess = shogi.Move(new Move("B2", "G7", true)); + + // Assert + using (new AssertionScope()) + { + moveSuccess.Should().BeTrue(); + shogi.Board["B2"].Should().BeNull(); + shogi.Board["G7"].Should().NotBeNull(); + shogi.Board["G7"].WhichPiece.Should().Be(WhichPiece.Bishop); + shogi.Board["G7"].Owner.Should().Be(WhichPerspective.Player1); + shogi.Board["G7"].IsPromoted.Should().BeTrue(); + } + } + + [Fact] + public void CheckMate() + { + // Arrange + var moves = new[] + { + // P1 Rook + new Move("H2", "E2"), + // P2 Gold + new Move("F9", "G8"), + // P1 Pawn + new Move("E3", "E4"), + // P2 other Gold + new Move("D9", "C8"), + // P1 same Pawn + new Move("E4", "E5"), + // P2 Pawn + new Move("E7", "E6"), + // P1 Pawn takes P2 Pawn + new Move("E5", "E6"), + // P2 King + new Move("E9", "E8"), + // P1 Pawn promotes, threatens P2 King + new Move("E6", "E7", true), + // P2 King retreat + new Move("E8", "E9"), + }; + var shogi = new Shogi(moves); + output.WriteLine(shogi.PrintStateAsAscii()); + + // Act - P1 Pawn wins by checkmate. + var moveSuccess = shogi.Move(new Move("E7", "E8")); + output.WriteLine(shogi.PrintStateAsAscii()); + + // Assert - checkmate + moveSuccess.Should().BeTrue(); + shogi.IsCheckmate.Should().BeTrue(); + shogi.InCheck.Should().Be(WhichPerspective.Player2); + } + + [Fact] + public void Capture() + { + // Arrange + var moves = new[] + { + new Move("C3", "C4"), + new Move("G7", "G6") + }; + var shogi = new Shogi(moves); + + // Act - P1 Bishop captures P2 Bishop + var moveSuccess = shogi.Move(new Move("B2", "H8")); + + // Assert + moveSuccess.Should().BeTrue(); + shogi.Board["B2"].Should().BeNull(); + shogi.Board["H8"].WhichPiece.Should().Be(WhichPiece.Bishop); + shogi.Board["H8"].Owner.Should().Be(WhichPerspective.Player1); + shogi.Board.Values + .Where(p => p != null) + .Should().ContainSingle(piece => piece.WhichPiece == WhichPiece.Bishop); + + shogi.Player1Hand + .Should() + .ContainSingle(p => p.WhichPiece == WhichPiece.Bishop && p.Owner == WhichPerspective.Player1); + } + } +} diff --git a/Gameboard.ShogiUI.xUnitTests/xunit.runner.json b/Gameboard.ShogiUI.xUnitTests/xunit.runner.json new file mode 100644 index 0000000..1d28022 --- /dev/null +++ b/Gameboard.ShogiUI.xUnitTests/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "methodDisplay": "method" +} diff --git a/PathFinding/Direction.cs b/PathFinding/Direction.cs new file mode 100644 index 0000000..2ee825d --- /dev/null +++ b/PathFinding/Direction.cs @@ -0,0 +1,18 @@ +using System.Numerics; + +namespace PathFinding +{ + public static class Direction + { + public static readonly Vector2 Up = new(0, 1); + public static readonly Vector2 Down = new(0, -1); + public static readonly Vector2 Left = new(-1, 0); + public static readonly Vector2 Right = new(1, 0); + public static readonly Vector2 UpLeft = new(-1, 1); + public static readonly Vector2 UpRight = new(1, 1); + public static readonly Vector2 DownLeft = new(-1, -1); + public static readonly Vector2 DownRight = new(1, -1); + public static readonly Vector2 KnightLeft = new(-1, 2); + public static readonly Vector2 KnightRight = new(1, 2); + } +} diff --git a/PathFinding/Distance.cs b/PathFinding/Distance.cs new file mode 100644 index 0000000..339296d --- /dev/null +++ b/PathFinding/Distance.cs @@ -0,0 +1,8 @@ +namespace PathFinding +{ + public enum Distance + { + OneStep, + MultiStep + } +} \ No newline at end of file diff --git a/PathFinding/IPlanarCollection.cs b/PathFinding/IPlanarCollection.cs new file mode 100644 index 0000000..0075dcd --- /dev/null +++ b/PathFinding/IPlanarCollection.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Numerics; + +namespace PathFinding +{ + public interface IPlanarCollection where T : IPlanarElement + { + T? this[Vector2 vector] { get; set; } + T? this[int x, int y] { get; set; } + } +} diff --git a/PathFinding/IPlanarElement.cs b/PathFinding/IPlanarElement.cs new file mode 100644 index 0000000..5d3fde4 --- /dev/null +++ b/PathFinding/IPlanarElement.cs @@ -0,0 +1,9 @@ + +namespace PathFinding +{ + public interface IPlanarElement + { + MoveSet MoveSet { get; } + bool IsUpsideDown { get; } + } +} diff --git a/PathFinding/Move.cs b/PathFinding/Move.cs new file mode 100644 index 0000000..5ff2c8e --- /dev/null +++ b/PathFinding/Move.cs @@ -0,0 +1,17 @@ +using System.Diagnostics; +using System.Numerics; + +namespace PathFinding +{ + [DebuggerDisplay("{Direction} - {Distance}")] + public class Move + { + public Vector2 Direction { get; } + public Distance Distance { get; } + public Move(Vector2 direction, Distance distance = Distance.OneStep) + { + Direction = direction; + Distance = distance; + } + } +} diff --git a/PathFinding/MoveSet.cs b/PathFinding/MoveSet.cs new file mode 100644 index 0000000..f6c7260 --- /dev/null +++ b/PathFinding/MoveSet.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace PathFinding +{ + public class MoveSet + { + private readonly IPlanarElement element; + private readonly ICollection moves; + private readonly ICollection upsidedownMoves; + + public MoveSet(IPlanarElement element, ICollection moves) + { + this.element = element; + this.moves = moves; + upsidedownMoves = moves.Select(_ => new Move(Vector2.Negate(_.Direction), _.Distance)).ToList(); + } + + public ICollection GetMoves() => element.IsUpsideDown ? upsidedownMoves : moves; + + } +} diff --git a/PathFinding/PathFinder2D.cs b/PathFinding/PathFinder2D.cs new file mode 100644 index 0000000..6a04823 --- /dev/null +++ b/PathFinding/PathFinder2D.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace PathFinding +{ + public class PathFinder2D where T : IPlanarElement + { + /// Guaranteed to be non-null. + /// + public delegate void Callback(T collider, Vector2 position); + + private readonly IPlanarCollection collection; + private readonly int width; + private readonly int height; + + /// Horizontal size, in steps, of the pathable plane. + /// Vertical size, in steps, of the pathable plane. + public PathFinder2D(IPlanarCollection collection, int width, int height) + { + this.collection = collection; + this.width = width; + this.height = height; + } + + /// + /// Navigate the collection such that each "step" is always towards the destination, respecting the Paths available to the element at origin. + /// + /// The pathing element. + /// The starting location. + /// The destination. + /// Do cool stuff here. + /// True if the element reached the destination. + public bool PathTo(Vector2 origin, Vector2 destination, Callback? callback = null) + { + if (destination.X > width - 1 || destination.Y > height - 1 || destination.X < 0 || destination.Y < 0) + { + return false; + } + var element = collection[origin]; + if (element == null) return false; + + var path = FindDirectionTowardsDestination(element.MoveSet.GetMoves(), origin, destination); + if (!IsPathable(origin, destination, path.Direction)) + { + // Assumption: if a single best-choice step towards the destination cannot happen, no pathing can happen. + return false; + } + + var shouldPath = true; + var next = origin; + while (shouldPath && next != destination) + { + next = Vector2.Add(next, path.Direction); + var collider = collection[next]; + if (collider != null) + { + callback?.Invoke(collider, next); + shouldPath = false; + } + else if (path.Distance == Distance.OneStep) + { + shouldPath = false; + } + } + return next == destination; + } + + public void PathEvery(Vector2 from, Callback callback) + { + var element = collection[from]; + if (element == null) + { + return; + } + foreach (var path in element.MoveSet.GetMoves()) + { + var shouldPath = true; + var next = Vector2.Add(from, path.Direction); ; + while (shouldPath && next.X < width && next.Y < height && next.X >= 0 && next.Y >= 0) + { + var collider = collection[(int)next.Y, (int)next.X]; + if (collider != null) + { + callback(collider, next); + shouldPath = false; + } + if (path.Distance == Distance.OneStep) + { + shouldPath = false; + } + next = Vector2.Add(next, path.Direction); + } + } + } + + /// + /// Path the line from origin to destination, ignoring any Paths defined by the element at origin. + /// + public void LinePathTo(Vector2 origin, Vector2 direction, Callback callback) + { + direction = Vector2.Normalize(direction); + + var next = Vector2.Add(origin, direction); + while (next.X >= 0 && next.X < width && next.Y >= 0 && next.Y < height) + { + var element = collection[next]; + if (element != null) callback(element, next); + next = Vector2.Add(next, direction); + } + } + + public static Move FindDirectionTowardsDestination(ICollection paths, Vector2 origin, Vector2 destination) => + paths.Aggregate((a, b) => + { + var distanceA = Vector2.Distance(destination, Vector2.Add(origin, a.Direction)); + var distanceB = Vector2.Distance(destination, Vector2.Add(origin, b.Direction)); + return distanceA < distanceB ? a : b; + }); + + public static bool IsPathable(Vector2 origin, Vector2 destination, Vector2 direction) + { + var next = Vector2.Add(origin, direction); + if (Vector2.Distance(next, destination) >= Vector2.Distance(origin, destination)) return false; + + var slope = (destination.Y - origin.Y) / (destination.X - origin.X); + if (float.IsInfinity(slope)) + { + return next.X == destination.X; + } + else + { + // b = -mx + y + var yIntercept = -slope * origin.X + origin.Y; + // y = mx + b + return next.Y == slope * next.X + yIntercept; + } + } + } +} diff --git a/PathFinding/PathFinding.csproj b/PathFinding/PathFinding.csproj new file mode 100644 index 0000000..6493b62 --- /dev/null +++ b/PathFinding/PathFinding.csproj @@ -0,0 +1,14 @@ + + + + net6.0 + true + 5 + enable + + + + + + + diff --git a/Shogi.Domain.UnitTests/Shogi.Domain.UnitTests.csproj b/Shogi.Domain.UnitTests/Shogi.Domain.UnitTests.csproj new file mode 100644 index 0000000..724e08f --- /dev/null +++ b/Shogi.Domain.UnitTests/Shogi.Domain.UnitTests.csproj @@ -0,0 +1,28 @@ + + + + net6.0 + enable + + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Shogi.Domain.UnitTests/ShogiBoardStateShould.cs b/Shogi.Domain.UnitTests/ShogiBoardStateShould.cs new file mode 100644 index 0000000..470a3cb --- /dev/null +++ b/Shogi.Domain.UnitTests/ShogiBoardStateShould.cs @@ -0,0 +1,186 @@ +using FluentAssertions; +using Xunit; + +namespace Shogi.Domain.UnitTests +{ + public class ShogiBoardStateShould + { + [Fact] + public void InitializeBoardState() + { + // Act + var board = new ShogiBoardState(); + + // 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); + + 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(); + + 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); + + 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(); + + 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(); + + 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(); + + 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); + + 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(); + + 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); + } + } +} diff --git a/Shogi.Domain.UnitTests/ShogiShould.cs b/Shogi.Domain.UnitTests/ShogiShould.cs new file mode 100644 index 0000000..2e8f734 --- /dev/null +++ b/Shogi.Domain.UnitTests/ShogiShould.cs @@ -0,0 +1,458 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using System; +using System.Linq; +using Xunit; +using Xunit.Abstractions; + +namespace Shogi.Domain.UnitTests +{ + public class ShogiShould + { + private readonly ITestOutputHelper output; + public ShogiShould(ITestOutputHelper output) + { + this.output = output; + } + + [Fact] + public void MoveAPieceToAnEmptyPosition() + { + // Arrange + var board = new ShogiBoardState(); + var shogi = new Shogi(board); + board["A4"].Should().BeNull(); + var expectedPiece = board["A3"]; + expectedPiece.Should().NotBeNull(); + + // Act + shogi.Move("A3", "A4", false); + + // Assert + board["A3"].Should().BeNull(); + board["A4"].Should().Be(expectedPiece); + } + + [Fact] + public void AllowValidMoves_AfterCheck() + { + // Arrange + var board = new ShogiBoardState(); + var shogi = new Shogi(board); + // 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 board = new ShogiBoardState(); + var shogi = new Shogi(board); + board["D5"].Should().BeNull(); + + // Act + var act = () => shogi.Move("D5", "D6", false); + + // Assert + act.Should().Throw(); + board["D5"].Should().BeNull(); + board["D6"].Should().BeNull(); + } + + [Fact] + public void PreventInvalidMoves_MoveToCurrentPosition() + { + // Arrange + var board = new ShogiBoardState(); + var shogi = new Shogi(board); + + // Act - P1 "moves" pawn to the position it already exists at. + var act = () => shogi.Move("A3", "A3", false); + + // Assert + act.Should().Throw(); + board["A3"].Should().NotBeNull(); + board["A3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); + board.Player1Hand.Should().BeEmpty(); + board.Player2Hand.Should().BeEmpty(); + } + + [Fact] + public void PreventInvalidMoves_MoveSet() + { + // Arrange + var board = new ShogiBoardState(); + var shogi = new Shogi(board); + + // Act - Move Lance illegally + var act = () => shogi.Move("A1", "D5", false); + + // Assert + act.Should().Throw(); + board["A1"].Should().NotBeNull(); + board["A1"]!.WhichPiece.Should().Be(WhichPiece.Lance); + board["A5"].Should().BeNull(); + board.Player1Hand.Should().BeEmpty(); + board.Player2Hand.Should().BeEmpty(); + } + + [Fact] + public void PreventInvalidMoves_Ownership() + { + // Arrange + var board = new ShogiBoardState(); + var shogi = new Shogi(board); + board.WhoseTurn.Should().Be(WhichPlayer.Player1); + board["A7"].Should().NotBeNull(); + board["A7"]!.Owner.Should().Be(WhichPlayer.Player2); + + // Act - Move Player2 Pawn when it is Player1 turn. + var act = () => shogi.Move("A7", "A6", false); + + // Assert + act.Should().Throw(); + board["A7"].Should().NotBeNull(); + board["A7"]!.WhichPiece.Should().Be(WhichPiece.Pawn); + board["A6"].Should().BeNull(); + } + + [Fact] + public void PreventInvalidMoves_MoveThroughAllies() + { + // Arrange + var board = new ShogiBoardState(); + var shogi = new Shogi(board); + + // Act - Move P1 Lance through P1 Pawn. + var act = () => shogi.Move("A1", "A5", false); + + // Assert + act.Should().Throw(); + board["A1"].Should().NotBeNull(); + board["A1"]!.WhichPiece.Should().Be(WhichPiece.Lance); + board["A3"].Should().NotBeNull(); + board["A3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); + board["A5"].Should().BeNull(); + } + + [Fact] + public void PreventInvalidMoves_CaptureAlly() + { + // Arrange + var board = new ShogiBoardState(); + var shogi = new Shogi(board); + + // Act - P1 Knight tries to capture P1 Pawn. + var act = () => shogi.Move("B1", "C3", false); + + // Arrange + act.Should().Throw(); + board["B1"].Should().NotBeNull(); + board["B1"]!.WhichPiece.Should().Be(WhichPiece.Knight); + board["C3"].Should().NotBeNull(); + board["C3"]!.WhichPiece.Should().Be(WhichPiece.Pawn); + board.Player1Hand.Should().BeEmpty(); + board.Player2Hand.Should().BeEmpty(); + } + + [Fact] + public void PreventInvalidMoves_Check() + { + // Arrange + var board = new ShogiBoardState(); + var shogi = new Shogi(board); + // 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 moves Lance while in check. + var act = () => shogi.Move("I9", "I8", false); + + // Assert + act.Should().Throw(); + board.InCheck.Should().Be(WhichPlayer.Player2); + board["I9"].Should().NotBeNull(); + board["I9"]!.WhichPiece.Should().Be(WhichPiece.Lance); + board["I8"].Should().BeNull(); + } + + [Fact] + public void PreventInvalidDrops_MoveSet() + { + // Arrange + var board = new ShogiBoardState(); + var shogi = new Shogi(board); + + + // 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 row. + board["H9"].Should().BeNull(); + shogi.Move() + var dropSuccess = shogi.Move(new Move(WhichPiece.Knight, "H9")); + dropSuccess.Should().BeFalse(); + shogi.Board["H9"].Should().BeNull(); + shogi.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Knight); + + // Act | Assert - Illegally placing Knight from the hand in second farthest row. + shogi.Board["H8"].Should().BeNull(); + dropSuccess = shogi.Move(new Move(WhichPiece.Knight, "H8")); + dropSuccess.Should().BeFalse(); + shogi.Board["H8"].Should().BeNull(); + shogi.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Knight); + + // Act | Assert - Illegally place Lance from the hand. + shogi.Board["H9"].Should().BeNull(); + dropSuccess = shogi.Move(new Move(WhichPiece.Knight, "H9")); + dropSuccess.Should().BeFalse(); + shogi.Board["H9"].Should().BeNull(); + shogi.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Lance); + + // Act | Assert - Illegally place Pawn from the hand. + shogi.Board["H9"].Should().BeNull(); + dropSuccess = shogi.Move(new Move(WhichPiece.Pawn, "H9")); + dropSuccess.Should().BeFalse(); + shogi.Board["H9"].Should().BeNull(); + shogi.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 moves = new[] + // { + // // P1 Pawn + // new Move("C3", "C4"), + // // P2 Pawn + // new Move("G7", "G6"), + // // P1 Pawn, arbitrary move. + // new Move("A3", "A4"), + // // P2 Bishop takes P1 Bishop + // new Move("H8", "B2"), + // // P1 Silver takes P2 Bishop + // new Move("C1", "B2"), + // // P2 Pawn, arbtrary move + // new Move("A7", "A6"), + // // P1 drop Bishop, place P2 in check + // new Move(WhichPiece.Bishop, "G7") + // }; + // var shogi = new Shogi(moves); + // shogi.InCheck.Should().Be(WhichPlayer.Player2); + // shogi.Player2Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop); + // shogi.Board["E5"].Should().BeNull(); + + // // Act - P2 places a Bishop while in check. + // var dropSuccess = shogi.Move(new Move(WhichPiece.Bishop, "E5")); + + // // Assert + // dropSuccess.Should().BeFalse(); + // shogi.Board["E5"].Should().BeNull(); + // shogi.InCheck.Should().Be(WhichPlayer.Player2); + // shogi.Player2Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop); + //} + + //[Fact] + //public void PreventInvalidDrop_Capture() + //{ + // // Arrange + // var moves = new[] + // { + // // P1 Pawn + // new Move("C3", "C4"), + // // P2 Pawn + // new Move("G7", "G6"), + // // P1 Bishop capture P2 Bishop + // new Move("B2", "H8"), + // // P2 Pawn + // new Move("G6", "G5") + // }; + // var shogi = new Shogi(moves); + // using (new AssertionScope()) + // { + // shogi.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop); + // shogi.Board["I9"].Should().NotBeNull(); + // shogi.Board["I9"].WhichPiece.Should().Be(WhichPiece.Lance); + // shogi.Board["I9"].Owner.Should().Be(WhichPlayer.Player2); + // } + + // // Act - P1 tries to place a piece where an opponent's piece resides. + // var dropSuccess = shogi.Move(new Move(WhichPiece.Bishop, "I9")); + + // // Assert + // using (new AssertionScope()) + // { + // dropSuccess.Should().BeFalse(); + // shogi.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop); + // shogi.Board["I9"].Should().NotBeNull(); + // shogi.Board["I9"].WhichPiece.Should().Be(WhichPiece.Lance); + // shogi.Board["I9"].Owner.Should().Be(WhichPlayer.Player2); + // } + //} + + //[Fact] + //public void Check() + //{ + // // Arrange + // var moves = new[] + // { + // // P1 Pawn + // new Move("C3", "C4"), + // // P2 Pawn + // new Move("G7", "G6"), + // }; + // var shogi = new Shogi(moves); + + // // Act - P1 Bishop, check + // shogi.Move(new Move("B2", "G7")); + + // // Assert + // shogi.InCheck.Should().Be(WhichPlayer.Player2); + //} + + //[Fact] + //public void Promote() + //{ + // // Arrange + // var moves = new[] + // { + // // P1 Pawn + // new Move("C3", "C4" ), + // // P2 Pawn + // new Move("G7", "G6" ) + // }; + // var shogi = new Shogi(moves); + + // // Act - P1 moves across promote threshold. + // var moveSuccess = shogi.Move(new Move("B2", "G7", true)); + + // // Assert + // using (new AssertionScope()) + // { + // moveSuccess.Should().BeTrue(); + // shogi.Board["B2"].Should().BeNull(); + // shogi.Board["G7"].Should().NotBeNull(); + // shogi.Board["G7"].WhichPiece.Should().Be(WhichPiece.Bishop); + // shogi.Board["G7"].Owner.Should().Be(WhichPlayer.Player1); + // shogi.Board["G7"].IsPromoted.Should().BeTrue(); + // } + //} + + //[Fact] + //public void CheckMate() + //{ + // // Arrange + // var moves = new[] + // { + // // P1 Rook + // new Move("H2", "E2"), + // // P2 Gold + // new Move("F9", "G8"), + // // P1 Pawn + // new Move("E3", "E4"), + // // P2 other Gold + // new Move("D9", "C8"), + // // P1 same Pawn + // new Move("E4", "E5"), + // // P2 Pawn + // new Move("E7", "E6"), + // // P1 Pawn takes P2 Pawn + // new Move("E5", "E6"), + // // P2 King + // new Move("E9", "E8"), + // // P1 Pawn promotes, threatens P2 King + // new Move("E6", "E7", true), + // // P2 King retreat + // new Move("E8", "E9"), + // }; + // var shogi = new Shogi(moves); + // output.WriteLine(shogi.PrintStateAsAscii()); + + // // Act - P1 Pawn wins by checkmate. + // var moveSuccess = shogi.Move(new Move("E7", "E8")); + // output.WriteLine(shogi.PrintStateAsAscii()); + + // // Assert - checkmate + // moveSuccess.Should().BeTrue(); + // shogi.IsCheckmate.Should().BeTrue(); + // shogi.InCheck.Should().Be(WhichPlayer.Player2); + //} + + //[Fact] + //public void Capture() + //{ + // // Arrange + // var moves = new[] + // { + // new Move("C3", "C4"), + // new Move("G7", "G6") + // }; + // var shogi = new Shogi(moves); + + // // Act - P1 Bishop captures P2 Bishop + // var moveSuccess = shogi.Move(new Move("B2", "H8")); + + // // Assert + // moveSuccess.Should().BeTrue(); + // shogi.Board["B2"].Should().BeNull(); + // shogi.Board["H8"].WhichPiece.Should().Be(WhichPiece.Bishop); + // shogi.Board["H8"].Owner.Should().Be(WhichPlayer.Player1); + // shogi.Board.Values + // .Where(p => p != null) + // .Should().ContainSingle(piece => piece.WhichPiece == WhichPiece.Bishop); + + // shogi.Player1Hand + // .Should() + // .ContainSingle(p => p.WhichPiece == WhichPiece.Bishop && p.Owner == WhichPlayer.Player1); + //} + } +} diff --git a/Shogi.Domain.UnitTests/StandardRulesShould.cs b/Shogi.Domain.UnitTests/StandardRulesShould.cs new file mode 100644 index 0000000..7f132f3 --- /dev/null +++ b/Shogi.Domain.UnitTests/StandardRulesShould.cs @@ -0,0 +1,31 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Xunit; + +namespace Shogi.Domain.UnitTests +{ + public class StandardRulesShould + { + [Fact] + public void AllowValidMoves_AfterCheck() + { + // Arrange + var board = new ShogiBoardState(); + var rules = new StandardRules(board); + rules.Move("C3", "C4"); // P1 Pawn + rules.Move("G7", "G6"); // P2 Pawn + rules.Move("B2", "G7"); // P1 Bishop puts P2 in check + board.InCheck.Should().Be(WhichPlayer.Player2); + + // Act - P2 is able to un-check theirself. + /// P2 King moves out of check + var moveSuccess = rules.Move("E9", "E8"); + + // Assert + using var _ = new AssertionScope(); + moveSuccess.Success.Should().BeTrue(); + moveSuccess.Reason.Should().BeEmpty(); + board.InCheck.Should().BeNull(); + } + } +} diff --git a/Shogi.Domain/Direction.cs b/Shogi.Domain/Direction.cs new file mode 100644 index 0000000..5316598 --- /dev/null +++ b/Shogi.Domain/Direction.cs @@ -0,0 +1,18 @@ +using System.Numerics; + +namespace Shogi.Domain +{ + public static class Direction + { + public static readonly Vector2 Up = new(0, 1); + public static readonly Vector2 Down = new(0, -1); + public static readonly Vector2 Left = new(-1, 0); + public static readonly Vector2 Right = new(1, 0); + public static readonly Vector2 UpLeft = new(-1, 1); + public static readonly Vector2 UpRight = new(1, 1); + public static readonly Vector2 DownLeft = new(-1, -1); + public static readonly Vector2 DownRight = new(1, -1); + public static readonly Vector2 KnightLeft = new(-1, 2); + public static readonly Vector2 KnightRight = new(1, 2); + } +} diff --git a/Shogi.Domain/Distance.cs b/Shogi.Domain/Distance.cs new file mode 100644 index 0000000..9031228 --- /dev/null +++ b/Shogi.Domain/Distance.cs @@ -0,0 +1,8 @@ +namespace Shogi.Domain +{ + public enum Distance + { + OneStep, + MultiStep + } +} \ No newline at end of file diff --git a/Shogi.Domain/Move.cs b/Shogi.Domain/Move.cs new file mode 100644 index 0000000..1ad6f08 --- /dev/null +++ b/Shogi.Domain/Move.cs @@ -0,0 +1,17 @@ +using System.Diagnostics; +using System.Numerics; + +namespace Shogi.Domain +{ + [DebuggerDisplay("{Direction} - {Distance}")] + public class Move + { + public Vector2 Direction { get; } + public Distance Distance { get; } + public Move(Vector2 direction, Distance distance = Distance.OneStep) + { + Direction = direction; + Distance = distance; + } + } +} diff --git a/Shogi.Domain/MoveResult.cs b/Shogi.Domain/MoveResult.cs new file mode 100644 index 0000000..fed07d6 --- /dev/null +++ b/Shogi.Domain/MoveResult.cs @@ -0,0 +1,14 @@ +namespace Shogi.Domain +{ + public class MoveResult + { + public bool Success { get; } + public string Reason { get; } + + public MoveResult(bool isSuccess, string reason = "") + { + Success = isSuccess; + Reason = reason; + } + } +} diff --git a/Shogi.Domain/MoveSet.cs b/Shogi.Domain/MoveSet.cs new file mode 100644 index 0000000..b1b2d2d --- /dev/null +++ b/Shogi.Domain/MoveSet.cs @@ -0,0 +1,106 @@ +using System.Numerics; + +namespace Shogi.Domain +{ + public class MoveSet + { + + public static readonly MoveSet King = new(new List(8) + { + new Move(Direction.Up), + new Move(Direction.Left), + new Move(Direction.Right), + new Move(Direction.Down), + new Move(Direction.UpLeft), + new Move(Direction.UpRight), + new Move(Direction.DownLeft), + new Move(Direction.DownRight) + }); + + public static readonly MoveSet Bishop = new(new List(4) + { + new Move(Direction.UpLeft, Distance.MultiStep), + new Move(Direction.UpRight, Distance.MultiStep), + new Move(Direction.DownLeft, Distance.MultiStep), + new Move(Direction.DownRight, Distance.MultiStep) + }); + + public static readonly MoveSet PromotedBishop = new(new List(8) + { + new Move(Direction.Up), + new Move(Direction.Left), + new Move(Direction.Right), + new Move(Direction.Down), + new Move(Direction.UpLeft, Distance.MultiStep), + new Move(Direction.UpRight, Distance.MultiStep), + new Move(Direction.DownLeft, Distance.MultiStep), + new Move(Direction.DownRight, Distance.MultiStep) + }); + + public static readonly MoveSet GoldGeneral = new(new List(6) + { + new Move(Direction.Up), + new Move(Direction.UpLeft), + new Move(Direction.UpRight), + new Move(Direction.Left), + new Move(Direction.Right), + new Move(Direction.Down) + }); + + public static readonly MoveSet Knight = new(new List(2) + { + new Move(Direction.KnightLeft), + new Move(Direction.KnightRight) + }); + + public static readonly MoveSet Lance = new(new List(1) + { + new Move(Direction.Up, Distance.MultiStep), + }); + + public static readonly MoveSet Pawn = new(new List(1) + { + new Move(Direction.Up) + }); + + public static readonly MoveSet Rook = new(new List(4) + { + new Move(Direction.Up, Distance.MultiStep), + new Move(Direction.Left, Distance.MultiStep), + new Move(Direction.Right, Distance.MultiStep), + new Move(Direction.Down, Distance.MultiStep) + }); + + public static readonly MoveSet PromotedRook = new(new List(8) + { + new Move(Direction.Up, Distance.MultiStep), + new Move(Direction.Left, Distance.MultiStep), + new Move(Direction.Right, Distance.MultiStep), + new Move(Direction.Down, Distance.MultiStep), + new Move(Direction.UpLeft), + new Move(Direction.UpRight), + new Move(Direction.DownLeft), + new Move(Direction.DownRight) + }); + + public static readonly MoveSet SilverGeneral = new(new List(4) + { + new Move(Direction.Up), + new Move(Direction.UpLeft), + new Move(Direction.UpRight), + new Move(Direction.DownLeft), + new Move(Direction.DownRight) + }); + + private readonly ICollection moves; + private readonly ICollection upsidedownMoves; + + private MoveSet(ICollection moves) + { + this.moves = moves; + upsidedownMoves = moves.Select(m => new Move(Vector2.Negate(m.Direction), m.Distance)).ToList(); + } + + public ICollection GetMoves(bool isUpsideDown) => isUpsideDown ? upsidedownMoves : moves; + } +} diff --git a/Shogi.Domain/Piece.cs b/Shogi.Domain/Piece.cs new file mode 100644 index 0000000..0ccc94f --- /dev/null +++ b/Shogi.Domain/Piece.cs @@ -0,0 +1,44 @@ +using System.Diagnostics; + +namespace Shogi.Domain +{ + [DebuggerDisplay("{WhichPiece} {Owner}")] + public class Piece + { + public WhichPiece WhichPiece { get; } + public WhichPlayer Owner { get; private set; } + public bool IsPromoted { get; private set; } + public bool IsUpsideDown => Owner == WhichPlayer.Player2; + + public Piece(WhichPiece piece, WhichPlayer owner, bool isPromoted = false) + { + WhichPiece = piece; + Owner = owner; + IsPromoted = isPromoted; + } + public Piece(Piece piece) : this(piece.WhichPiece, piece.Owner, piece.IsPromoted) + { + } + + public bool CanPromote => !IsPromoted + && WhichPiece != WhichPiece.King + && WhichPiece != WhichPiece.GoldGeneral; + + public void ToggleOwnership() + { + Owner = Owner == WhichPlayer.Player1 + ? WhichPlayer.Player2 + : WhichPlayer.Player1; + } + + public void Promote() => IsPromoted = CanPromote; + + public void Demote() => IsPromoted = false; + + public void Capture() + { + ToggleOwnership(); + Demote(); + } + } +} diff --git a/Shogi.Domain/Shogi.Domain.csproj b/Shogi.Domain/Shogi.Domain.csproj new file mode 100644 index 0000000..132c02c --- /dev/null +++ b/Shogi.Domain/Shogi.Domain.csproj @@ -0,0 +1,9 @@ + + + + net6.0 + enable + enable + + + diff --git a/Shogi.Domain/Shogi.cs b/Shogi.Domain/Shogi.cs new file mode 100644 index 0000000..b895ffa --- /dev/null +++ b/Shogi.Domain/Shogi.cs @@ -0,0 +1,112 @@ +namespace Shogi.Domain +{ + /// + /// Facilitates Shogi board state transitions, cognisant of Shogi rules. + /// The board is always from Player1's perspective. + /// [0,0] is the lower-left position, [8,8] is the higher-right position + /// + public sealed class Shogi + { + private readonly ShogiBoardState board; + private readonly StandardRules rules; + public string Error { get; private set; } + + public Shogi(ShogiBoardState board) + { + this.board = board; + rules = new StandardRules(this.board); + } + + //public Shogi(IList moves) : this(new ShogiBoardState()) + //{ + // for (var i = 0; i < moves.Count; i++) + // { + // if (!Move(moves[i])) + // { + // throw new InvalidOperationException($"Unable to construct ShogiBoard with the given move at index {i}."); + // } + // } + //} + + + public MoveResult CanMove(string from, string to, bool isPromotion) + { + var simulator = new StandardRules(new ShogiBoardState(board)); + return simulator.Move(from, to, isPromotion); + } + + public MoveResult CanMove(WhichPiece pieceInHand, string to) + { + var simulator = new StandardRules(new ShogiBoardState(board)); + return simulator.Move(pieceInHand, to); + } + + public void Move(string from, string to, bool isPromotion) + { + var tempBoard = new ShogiBoardState(board); + var simulation = new StandardRules(tempBoard); + var moveResult = simulation.Move(from, to, isPromotion); + if (!moveResult.Success) + { + throw new InvalidOperationException(moveResult.Reason); + } + + var fromVector = ShogiBoardState.FromBoardNotation(from); + var toVector = ShogiBoardState.FromBoardNotation(to); + var otherPlayer = board.WhoseTurn == WhichPlayer.Player1 ? WhichPlayer.Player2 : WhichPlayer.Player1; + if (simulation.IsPlayerInCheckAfterMove(fromVector, toVector, board.WhoseTurn)) + { + throw new InvalidOperationException("Illegal move. This move places you in check."); + } + + rules.Move(from, to, isPromotion); + if (rules.IsPlayerInCheckAfterMove(fromVector, toVector, otherPlayer)) + { + board.InCheck = otherPlayer; + board.IsCheckmate = rules.EvaluateCheckmate(); + } + else + { + board.InCheck = null; + } + board.WhoseTurn = otherPlayer; + } + + public void Move(WhichPiece pieceInHand, string to) + { + + } + ///// + ///// Attempts a given move. Returns false if the move is illegal. + ///// + //private bool TryMove(Move move) + //{ + // // Try making the move in a "throw away" board. + // var simulator = new StandardRules(new ShogiBoardState(this.board)); + + // var simulatedMoveResults = move.PieceFromHand.HasValue + // ? simulator.PlaceFromHand(move) + // : simulator.PlaceFromBoard(move); + // if (!simulatedMoveResults) + // { + // // Surface the error description. + // Error = simulationBoard.Error; + // return false; + // } + // // If already in check, assert the move that resulted in check no longer results in check. + // if (InCheck == WhoseTurn) + // { + // // Sneakily using this.WhoseTurn instead of validationBoard.WhoseTurn; + // if (simulationBoard.EvaluateCheckAfterMove(MoveHistory[^1], WhoseTurn)) + // { + // return false; + // } + // } + + // // The move is valid and legal; update board state. + // if (move.PieceFromHand.HasValue) PlaceFromHand(move); + // else PlaceFromBoard(move); + // return true; + //} + } +} diff --git a/Shogi.Domain/ShogiBoardState.cs b/Shogi.Domain/ShogiBoardState.cs new file mode 100644 index 0000000..3d240bf --- /dev/null +++ b/Shogi.Domain/ShogiBoardState.cs @@ -0,0 +1,202 @@ +using System.Numerics; +using System.Text.RegularExpressions; + +namespace Shogi.Domain +{ + // TODO: Avoid extending dictionary. Use composition instead. + // Then validation can occur when assigning a piece to a position. + public class ShogiBoardState + { + private static readonly string BoardNotationRegex = @"(?[a-iA-I])(?[1-9])"; + private static readonly char A = 'A'; + public delegate void ForEachDelegate(Piece element, Vector2 position); + /// + /// Key is position notation, such as "E4". + /// + private readonly Dictionary board; + + public List Hand => WhoseTurn == WhichPlayer.Player1 ? Player1Hand : Player2Hand; + public List Player1Hand { get; } + public List Player2Hand { get; } + public List MoveHistory { get; } + public WhichPlayer WhoseTurn { get; set; } + public WhichPlayer? InCheck { get; set; } + public bool IsCheckmate { get; set; } + + public ShogiBoardState() + { + board = new Dictionary(81); + InitializeBoardState(); + Player1Hand = new List(); + Player2Hand = new List(); + MoveHistory = new List(); + } + + + /// + /// Copy constructor. + /// + public ShogiBoardState(ShogiBoardState other) : this() + { + foreach (var kvp in other.board) + { + board[kvp.Key] = kvp.Value == null ? null : new Piece(kvp.Value); + } + WhoseTurn = other.WhoseTurn; + InCheck = other.InCheck; + IsCheckmate = other.IsCheckmate; + MoveHistory.AddRange(other.MoveHistory); + Player1Hand.AddRange(other.Player1Hand); + Player2Hand.AddRange(other.Player2Hand); + } + + public Piece? this[string notation] + { + // TODO: Validate "notation" here and throw an exception if invalid. + get => board[notation.ToUpper()]; + set => board[notation.ToUpper()] = value; + } + + public Piece? this[Vector2 vector] + { + get => this[ToBoardNotation(vector)]; + set => this[ToBoardNotation(vector)] = value; + } + + public Piece? this[int x, int y] + { + get => this[ToBoardNotation(x, y)]; + set => this[ToBoardNotation(x, y)] = value; + } + + public void ForEachNotNull(ForEachDelegate callback) + { + for (var x = 0; x < 9; x++) + { + for (var y = 0; y < 9; y++) + { + var position = new Vector2(x, y); + var elem = this[position]; + if (elem != null) + callback(elem, position); + } + } + } + + 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) + { + notation = notation.ToUpper(); + if (Regex.IsMatch(notation, BoardNotationRegex)) + { + var match = Regex.Match(notation, BoardNotationRegex); + 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}"); + } + + private void InitializeBoardState() + { + this["A1"] = new Piece(WhichPiece.Lance, WhichPlayer.Player1); + this["B1"] = new Piece(WhichPiece.Knight, WhichPlayer.Player1); + this["C1"] = new Piece(WhichPiece.SilverGeneral, WhichPlayer.Player1); + this["D1"] = new Piece(WhichPiece.GoldGeneral, WhichPlayer.Player1); + this["E1"] = new Piece(WhichPiece.King, WhichPlayer.Player1); + this["F1"] = new Piece(WhichPiece.GoldGeneral, WhichPlayer.Player1); + this["G1"] = new Piece(WhichPiece.SilverGeneral, WhichPlayer.Player1); + this["H1"] = new Piece(WhichPiece.Knight, WhichPlayer.Player1); + this["I1"] = new Piece(WhichPiece.Lance, WhichPlayer.Player1); + + this["A2"] = null; + this["B2"] = new Piece(WhichPiece.Bishop, WhichPlayer.Player1); + this["C2"] = null; + this["D2"] = null; + this["E2"] = null; + this["F2"] = null; + this["G2"] = null; + this["H2"] = new Piece(WhichPiece.Rook, WhichPlayer.Player1); + this["I2"] = null; + + this["A3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + this["B3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + this["C3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + this["D3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + this["E3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + this["F3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + this["G3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + this["H3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + this["I3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + + this["A4"] = null; + this["B4"] = null; + this["C4"] = null; + this["D4"] = null; + this["E4"] = null; + this["F4"] = null; + this["G4"] = null; + this["H4"] = null; + this["I4"] = null; + + this["A5"] = null; + this["B5"] = null; + this["C5"] = null; + this["D5"] = null; + this["E5"] = null; + this["F5"] = null; + this["G5"] = null; + this["H5"] = null; + this["I5"] = null; + + this["A6"] = null; + this["B6"] = null; + this["C6"] = null; + this["D6"] = null; + this["E6"] = null; + this["F6"] = null; + this["G6"] = null; + this["H6"] = null; + this["I6"] = null; + + this["A7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + this["B7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + this["C7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + this["D7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + this["E7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + this["F7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + this["G7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + this["H7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + this["I7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + + this["A8"] = null; + this["B8"] = new Piece(WhichPiece.Rook, WhichPlayer.Player2); + this["C8"] = null; + this["D8"] = null; + this["E8"] = null; + this["F8"] = null; + this["G8"] = null; + this["H8"] = new Piece(WhichPiece.Bishop, WhichPlayer.Player2); + this["I8"] = null; + + this["A9"] = new Piece(WhichPiece.Lance, WhichPlayer.Player2); + this["B9"] = new Piece(WhichPiece.Knight, WhichPlayer.Player2); + this["C9"] = new Piece(WhichPiece.SilverGeneral, WhichPlayer.Player2); + this["D9"] = new Piece(WhichPiece.GoldGeneral, WhichPlayer.Player2); + this["E9"] = new Piece(WhichPiece.King, WhichPlayer.Player2); + this["F9"] = new Piece(WhichPiece.GoldGeneral, WhichPlayer.Player2); + this["G9"] = new Piece(WhichPiece.SilverGeneral, WhichPlayer.Player2); + this["H9"] = new Piece(WhichPiece.Knight, WhichPlayer.Player2); + this["I9"] = new Piece(WhichPiece.Lance, WhichPlayer.Player2); + } + } +} diff --git a/Shogi.Domain/StandardRules.cs b/Shogi.Domain/StandardRules.cs new file mode 100644 index 0000000..27e1f70 --- /dev/null +++ b/Shogi.Domain/StandardRules.cs @@ -0,0 +1,410 @@ +using System.Numerics; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Shogi.Domain.UnitTests")] +namespace Shogi.Domain +{ + internal class StandardRules + { + /// Guaranteed to be non-null. + /// + public delegate void Callback(Piece collider, Vector2 position); + + private readonly ShogiBoardState board; + private Vector2 player1KingPosition; + private Vector2 player2KingPosition; + + public StandardRules(ShogiBoardState board) + { + this.board = board; + CacheKingPositions(); + } + + private void CacheKingPositions() + { + this.board.ForEachNotNull((tile, position) => + { + if (tile.WhichPiece == WhichPiece.King) + { + if (tile.Owner == WhichPlayer.Player1) + { + player1KingPosition = position; + } + else if (tile.Owner == WhichPlayer.Player2) + { + player2KingPosition = position; + } + } + }); + } + + /// + /// Move a piece from a board tile to another board tile ignorant of check or check-mate. + /// + /// The position of the piece being moved expressed in board notation. + /// The target position expressed in board notation. + /// A describing the success or failure of the move. + public MoveResult Move(string fromNotation, string toNotation, bool isPromotion = false) + { + var from = ShogiBoardState.FromBoardNotation(fromNotation); + var to = ShogiBoardState.FromBoardNotation(toNotation); + var fromPiece = board[from]; + if (fromPiece == null) + { + return new MoveResult(false, $"Tile [{fromNotation}] is empty. There is no piece to move."); + } + + if (fromPiece.Owner != board.WhoseTurn) + { + return new MoveResult(false, "Not allowed to move the opponents piece"); + } + + if (ShogiIsPathable(from, to) == false) + { + return new MoveResult(false, $"Proposed move is not part of the move-set for piece {fromPiece.WhichPiece}."); + } + + var captured = board[to]; + if (captured != null) + { + if (captured.Owner == board.WhoseTurn) + { + return new MoveResult(false, "Capturing your own piece is not allowed."); + } + captured.Capture(); + board.Hand.Add(captured); + } + + //Mutate the board. + if (isPromotion) + { + if (board.WhoseTurn == WhichPlayer.Player1 && (to.Y > 5 || from.Y > 5)) + { + fromPiece.Promote(); + } + else if (board.WhoseTurn == WhichPlayer.Player2 && (to.Y < 3 || from.Y < 3)) + { + fromPiece.Promote(); + } + } + board[to] = fromPiece; + board[from] = null; + if (fromPiece.WhichPiece == WhichPiece.King) + { + if (fromPiece.Owner == WhichPlayer.Player1) + { + player1KingPosition = from; + } + else if (fromPiece.Owner == WhichPlayer.Player2) + { + player2KingPosition = from; + } + } + //MoveHistory.Add(move); + return new MoveResult(true); + } + + /// + /// Move a piece from the hand to the board ignorant if check or check-mate. + /// + /// + /// The target position expressed in board notation. + /// A describing the success or failure of the simulation. + public MoveResult Move(WhichPiece pieceInHand, string toNotation) + { + var to = ShogiBoardState.FromBoardNotation(toNotation); + var index = board.Hand.FindIndex(p => p.WhichPiece == pieceInHand); + if (index == -1) + { + return new MoveResult(false, $"{pieceInHand} does not exist in the hand."); + } + if (board[to] != null) + { + return new MoveResult(false, $"Illegal move - attempting to capture while playing a piece from the hand."); + } + + switch (pieceInHand) + { + case WhichPiece.Knight: + { + // Knight cannot be placed onto the farthest two ranks from the hand. + if ((board.WhoseTurn == WhichPlayer.Player1 && to.Y > 6) + || (board.WhoseTurn == WhichPlayer.Player2 && to.Y < 2)) + { + return new MoveResult(false, "Knight has no valid moves after placed."); + } + break; + } + case WhichPiece.Lance: + case WhichPiece.Pawn: + { + // Lance and Pawn cannot be placed onto the farthest rank from the hand. + if ((board.WhoseTurn == WhichPlayer.Player1 && to.Y == 8) + || (board.WhoseTurn == WhichPlayer.Player2 && to.Y == 0)) + { + return new MoveResult(false, $"{pieceInHand} has no valid moves after placed."); + } + break; + } + } + + // Mutate the board. + board[to] = board.Hand[index]; + board.Hand.RemoveAt(index); + //MoveHistory.Add(move); + return new MoveResult(true); + } + + private bool ShogiIsPathable(Vector2 from, Vector2 to) + { + var piece = board[from]; + if (piece == null) return false; + + var isObstructed = false; + var isPathable = PathTo(from, to, (other, position) => + { + if (other.Owner == piece.Owner) isObstructed = true; + }); + return !isObstructed && isPathable; + } + + public bool EvaluateCheckAfterMove(WhichPiece pieceInHand, Vector2 to, WhichPlayer whichPlayer) + { + if (whichPlayer == board.InCheck) return true; // If we already know the player is in check, don't bother. + + var isCheck = false; + var kingPosition = whichPlayer == WhichPlayer.Player1 ? player1KingPosition : player2KingPosition; + + // Check if the move put the king in check. + if (PathTo(to, kingPosition)) return true; + + // TODO: Check for illegal move from hand. It is illegal to place from the hand such that you check-mate your opponent. + // Go read the shogi rules to be sure this is true. + + return isCheck; + } + + public bool IsPlayerInCheckAfterMove(Vector2 from, Vector2 to, WhichPlayer whichPlayer) + { + if (whichPlayer == board.InCheck) return true; // If we already know the player is in check, don't bother. + + var isCheck = false; + var kingPosition = whichPlayer == WhichPlayer.Player1 ? player1KingPosition : player2KingPosition; + + // Check if the move put the king in check. + if (PathTo(to, kingPosition)) return true; + + // Get line equation from king through the now-unoccupied location. + var direction = Vector2.Subtract(kingPosition, from); + var slope = Math.Abs(direction.Y / direction.X); + // 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)) + { + // if slope of the move is also infinity...can skip this? + LinePathTo(kingPosition, direction, (piece, position) => + { + if (piece.Owner != whichPlayer) + { + switch (piece.WhichPiece) + { + case WhichPiece.Rook: + isCheck = true; + break; + case WhichPiece.Lance: + if (!piece.IsPromoted) isCheck = true; + break; + } + } + }); + } + else if (slope == 1) + { + LinePathTo(kingPosition, direction, (piece, position) => + { + if (piece.Owner != whichPlayer && piece.WhichPiece == WhichPiece.Bishop) + { + isCheck = true; + } + }); + } + else if (slope == 0) + { + LinePathTo(kingPosition, direction, (piece, position) => + { + if (piece.Owner != whichPlayer && piece.WhichPiece == WhichPiece.Rook) + { + isCheck = true; + } + }); + } + + return isCheck; + } + + public bool EvaluateCheckmate() + { + if (!board.InCheck.HasValue) return false; + + // Assume true and try to disprove. + var isCheckmate = true; + board.ForEachNotNull((piece, from) => // For each piece... + { + // Short circuit + if (!isCheckmate) return; + + if (piece.Owner == board.InCheck) // ...owned by the player in check... + { + // ...evaluate if any move gets the player out of check. + PathEvery(from, (other, position) => + { + var simulationBoard = new StandardRules(new ShogiBoardState(board)); + var fromNotation = ShogiBoardState.ToBoardNotation(from); + var toNotation = ShogiBoardState.ToBoardNotation(position); + var simulationResult = simulationBoard.Move(fromNotation, toNotation, false); + if (simulationResult.Success) + { + if (!IsPlayerInCheckAfterMove(from, position, board.InCheck.Value)) + { + isCheckmate = false; + } + } + }); + } + // TODO: Assert that a player could not place a piece from their hand to avoid check. + }); + return isCheckmate; + } + + /// + /// Navigate the collection such that each "step" is always towards the destination, respecting the Paths available to the element at origin. + /// + /// The pathing element. + /// The starting location. + /// The destination. + /// Do cool stuff here. + /// True if the element reached the destination. + public bool PathTo(Vector2 origin, Vector2 destination, Callback? callback = null) + { + if (destination.X > 8 || destination.Y > 8 || destination.X < 0 || destination.Y < 0) + { + return false; + } + var piece = board[origin]; + if (piece == null) return false; + + var path = FindDirectionTowardsDestination(GetMoveSet(piece.WhichPiece).GetMoves(piece.IsUpsideDown), origin, destination); + if (!IsPathable(origin, destination, path.Direction)) + { + // Assumption: if a single best-choice step towards the destination cannot happen, no pathing can happen. + return false; + } + + var shouldPath = true; + var next = origin; + while (shouldPath && next != destination) + { + next = Vector2.Add(next, path.Direction); + var collider = board[next]; + if (collider != null) + { + callback?.Invoke(collider, next); + shouldPath = false; + } + else if (path.Distance == Distance.OneStep) + { + shouldPath = false; + } + } + return next == destination; + } + + public void PathEvery(Vector2 from, Callback callback) + { + var piece = board[from]; + if (piece == null) + { + return; + } + foreach (var path in GetMoveSet(piece.WhichPiece).GetMoves(piece.IsUpsideDown)) + { + var shouldPath = true; + var next = Vector2.Add(from, path.Direction); ; + while (shouldPath && next.X < 8 && next.Y < 8 && next.X >= 0 && next.Y >= 0) + { + var collider = board[(int)next.Y, (int)next.X]; + if (collider != null) + { + callback(collider, next); + shouldPath = false; + } + if (path.Distance == Distance.OneStep) + { + shouldPath = false; + } + next = Vector2.Add(next, path.Direction); + } + } + } + + public static bool IsPathable(Vector2 origin, Vector2 destination, Vector2 direction) + { + var next = Vector2.Add(origin, direction); + if (Vector2.Distance(next, destination) >= Vector2.Distance(origin, destination)) return false; + + var slope = (destination.Y - origin.Y) / (destination.X - origin.X); + if (float.IsInfinity(slope)) + { + return next.X == destination.X; + } + else + { + // b = -mx + y + var yIntercept = -slope * origin.X + origin.Y; + // y = mx + b + return next.Y == slope * next.X + yIntercept; + } + } + + /// + /// Path the line from origin to destination, ignoring any Paths defined by the element at origin. + /// + public void LinePathTo(Vector2 origin, Vector2 direction, Callback callback) + { + direction = Vector2.Normalize(direction); + + var next = Vector2.Add(origin, direction); + while (next.X >= 0 && next.X < 8 && next.Y >= 0 && next.Y < 8) + { + var element = board[next]; + if (element != null) callback(element, next); + next = Vector2.Add(next, direction); + } + } + + public static Move FindDirectionTowardsDestination(ICollection paths, Vector2 origin, Vector2 destination) => + paths.Aggregate((a, b) => + { + var distanceA = Vector2.Distance(destination, Vector2.Add(origin, a.Direction)); + var distanceB = Vector2.Distance(destination, Vector2.Add(origin, b.Direction)); + return distanceA < distanceB ? a : b; + }); + + public static MoveSet GetMoveSet(WhichPiece whichPiece) + { + return whichPiece switch + { + WhichPiece.King => MoveSet.King, + WhichPiece.GoldGeneral => MoveSet.GoldGeneral, + WhichPiece.SilverGeneral => MoveSet.SilverGeneral, + WhichPiece.Bishop => MoveSet.Bishop, + WhichPiece.Rook => MoveSet.Rook, + WhichPiece.Knight => MoveSet.Knight, + WhichPiece.Lance => MoveSet.Lance, + WhichPiece.Pawn => MoveSet.Pawn, + _ => throw new ArgumentException($"{nameof(WhichPiece)} not recognized."), + }; + } + } +} diff --git a/Shogi.Domain/WhichPiece.cs b/Shogi.Domain/WhichPiece.cs new file mode 100644 index 0000000..58a1669 --- /dev/null +++ b/Shogi.Domain/WhichPiece.cs @@ -0,0 +1,14 @@ +namespace Shogi.Domain +{ + public enum WhichPiece + { + King, + GoldGeneral, + SilverGeneral, + Bishop, + Rook, + Knight, + Lance, + Pawn + } +} diff --git a/Shogi.Domain/WhichPlayer.cs b/Shogi.Domain/WhichPlayer.cs new file mode 100644 index 0000000..584d852 --- /dev/null +++ b/Shogi.Domain/WhichPlayer.cs @@ -0,0 +1,8 @@ +namespace Shogi.Domain +{ + public enum WhichPlayer + { + Player1, + Player2 + } +}