The big merge

This commit is contained in:
2022-05-10 17:10:41 -05:00
130 changed files with 6093 additions and 1550 deletions

1
.gitignore vendored
View File

@@ -52,3 +52,4 @@ Thumbs.db
#Luke
bin
obj
*.user

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.13.1" />
</ItemGroup>
</Project>

106
Benchmarking/Benchmarks.cs Normal file
View File

@@ -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<Benchmarks>();
Console.WriteLine("Done");
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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; }
/// <summary>
/// The perspective on the game of the requesting user.
/// </summary>
public WhichPerspective PlayerPerspective { get; set; }
public BoardState BoardState { get; set; }
public IList<Move> MoveHistory { get; set; }
}
}

View File

@@ -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<Game> PlayerHasJoinedSessions { get; set; }
public Collection<Game> AllOtherSessions { get; set; }
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -1,4 +1,4 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api.Messages
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api
{
public class PostGameInvitation
{

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,8 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api
{
public class PostSession
{
public string Name { get; set; }
public bool IsPrivate { get; set; }
}
}

View File

@@ -1,7 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>5</AnalysisLevel>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -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; }
/// <summary>
/// The player who created the game.
/// </summary>
public string PlayerName { get; set; }
public CreateGameResponse()
{
Action = ClientAction.CreateGame.ToString();
}
}
}

View File

@@ -0,0 +1,9 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Types;
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket
{
public interface IRequest
{
ClientAction Action { get; }
}
}

View File

@@ -0,0 +1,7 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket
{
public interface IResponse
{
string Action { get; }
}
}

View File

@@ -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; }
}
}

View File

@@ -1,8 +0,0 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Interfaces
{
public interface IResponse
{
string Action { get; }
string Error { get; set; }
}
}

View File

@@ -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; }
/// <summary>
/// The player who joined the game.
/// </summary>
public string PlayerName { get; set; }
public JoinGameResponse()
{
Action = ClientAction.JoinGame.ToString();
GameName = "";
PlayerName = "";
}
}
public class JoinByCodeResponse : JoinGameResponse, IResponse
{
public JoinByCodeResponse()
{
Action = ClientAction.JoinByCode.ToString();
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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();
}
}
}

View File

@@ -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<Game> Games { get; set; }
public ListGamesResponse(ClientAction action)
{
Action = action.ToString();
}
}
}

View File

@@ -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<Move> Moves { get; set; }
public string Error { get; set; }
public LoadGameResponse(ClientAction action)
{
Action = action.ToString();
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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; }
/// <summary>
/// The player that made the move.
/// </summary>
public string PlayerName { get; set; }
public MoveResponse()
{
Action = ClientAction.Move.ToString();
}
}
}

View File

@@ -1,13 +0,0 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types
{
public enum ClientAction
{
ListGames,
CreateGame,
JoinGame,
JoinByCode,
LoadGame,
Move,
KeepAlive
}
}

View File

@@ -1,8 +0,0 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types
{
public class Coords
{
public int X { get; set; }
public int Y { get; set; }
}
}

View File

@@ -1,8 +0,0 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket.Types
{
public class Game
{
public string GameName { get; set; }
public string[] Players { get; set; }
}
}

View File

@@ -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; }
/// <summary>
/// Toggles perspective of this move. (ie from player 1 to player 2)
/// </summary>
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;
}
}
}

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types
{
public class BoardState
{
public Dictionary<string, Piece?> Board { get; set; } = new Dictionary<string, Piece?>();
public IReadOnlyCollection<Piece> Player1Hand { get; set; } = Array.Empty<Piece>();
public IReadOnlyCollection<Piece> Player2Hand { get; set; } = Array.Empty<Piece>();
public WhichPerspective? PlayerInCheck { get; set; }
public WhichPerspective WhoseTurn { get; set; }
}
}

View File

@@ -0,0 +1,10 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types
{
public enum ClientAction
{
CreateGame,
JoinGame,
JoinByCode,
Move
}
}

View File

@@ -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;
/// <summary>
/// Players[0] is the session owner, Players[1] is the other person.
/// </summary>
public IReadOnlyList<string> Players
{
get
{
var list = new List<string>(2) { Player1 };
if (!string.IsNullOrEmpty(Player2)) list.Add(Player2);
return list;
}
}
/// <summary>
/// Constructor for serialization.
/// </summary>
public Game()
{
}
public Game(string gameName, string player1, string? player2 = null)
{
GameName = gameName;
Player1 = player1;
Player2 = player2;
}
}
}

View File

@@ -0,0 +1,12 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types
{
public class Move
{
public WhichPiece? PieceFromCaptured { get; set; }
/// <summary>Board position notation, like A3 or G1</summary>
public string? From { get; set; }
/// <summary>Board position notation, like A3 or G1</summary>
public string To { get; set; } = string.Empty;
public bool IsPromotion { get; set; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,9 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types
{
public enum WhichPerspective
{
Player1,
Player2,
Spectator
}
}

View File

@@ -0,0 +1,14 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types
{
public enum WhichPiece
{
King,
GoldGeneral,
SilverGeneral,
Bishop,
Rook,
Knight,
Lance,
Pawn
}
}

View File

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

View File

@@ -0,0 +1,39 @@
namespace Gameboard.ShogiUI.Sockets
{
namespace anonymous_session.Middlewares
{
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;
/// <summary>
/// TODO: Use this example in the guest session logic instead of custom claims.
/// </summary>
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);
}
}
}
}

View File

@@ -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]")]
[Authorize(Roles = "Shogi")]
public class GameController : ControllerBase
{
private readonly IGameboardRepositoryManager manager;
private readonly IGameboardRepository repository;
private readonly IGameboardManager gameboardManager;
private readonly IGameboardRepository gameboardRepository;
private readonly ISocketConnectionManager communicationManager;
public GameController(
IGameboardRepository repository,
IGameboardRepositoryManager manager)
IGameboardManager manager,
ISocketConnectionManager communicationManager)
{
this.manager = manager;
this.repository = repository;
gameboardManager = manager;
gameboardRepository = repository;
this.communicationManager = communicationManager;
}
[Route("JoinCode")]
[HttpPost("JoinCode")]
public async Task<IActionResult> 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
{
//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]
[Route("GuestJoinCode")]
[HttpPost("GuestJoinCode")]
public async Task<IActionResult> PostGuestGameInvitation([FromBody] PostGuestGameInvitation request)
{
var isGuest = manager.IsGuest(request.GuestId);
var isPlayer1 = manager.IsPlayer1(request.SessionName, request.GuestId);
if (isGuest && await isPlayer1)
//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<IActionResult> PostMove([FromRoute] string gameName, [FromBody] PostMove request)
{
var code = (await repository.PostJoinCode(request.SessionName, request.GuestId)).JoinCode;
return new CreatedResult("", new PostGameInvitationResponse(code));
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<IActionResult> 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<IActionResult> 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();
}
/// <summary>
/// Reads the board session and subscribes the caller to socket events for that session.
/// </summary>
[HttpGet("{gameName}")]
public async Task<IActionResult> 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<GetSessionsResponse> 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<Game>(sessionsJoinedByUser),
AllOtherSessions = new Collection<Game>(sessionsNotJoinedByUser)
};
}
[HttpPut("{gameName}")]
public async Task<IActionResult> 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<IActionResult> DeleteSession([FromRoute] string gameName)
{
var user = await ReadUserOrThrow();
if (user.IsAdmin)
{
return Ok();
}
else
{
return new UnauthorizedResult();
return Unauthorized();
}
}
private async Task<Models.User> ReadUserOrThrow()
{
var user = await gameboardManager.ReadUser(User);
if (user == null)
{
throw new UnauthorizedAccessException("Unknown user claims.");
}
return user;
}
}
}

View File

@@ -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]
[Route("[controller]")]
[Authorize(Roles = "Shogi")]
public class SocketController : ControllerBase
{
private readonly ISocketTokenManager tokenManager;
private readonly ILogger<SocketController> logger;
private readonly ISocketTokenCache tokenCache;
private readonly IGameboardManager gameboardManager;
private readonly IGameboardRepository gameboardRepository;
private readonly IGameboardRepositoryManager gameboardManager;
private readonly ISocketConnectionManager connectionManager;
private readonly AuthenticationProperties authenticationProps;
public SocketController(
ISocketTokenManager tokenManager,
ILogger<SocketController> logger,
ISocketTokenCache tokenCache,
IGameboardManager gameboardManager,
IGameboardRepository gameboardRepository,
IGameboardRepositoryManager gameboardManager)
ISocketConnectionManager connectionManager)
{
this.tokenManager = tokenManager;
this.gameboardRepository = gameboardRepository;
this.logger = logger;
this.tokenCache = tokenCache;
this.gameboardManager = gameboardManager;
this.gameboardRepository = gameboardRepository;
this.connectionManager = connectionManager;
authenticationProps = new AuthenticationProperties
{
AllowRefresh = true,
IsPersistent = true
};
}
[Route("Token")]
public IActionResult GetToken()
[HttpGet("GuestLogout")]
[AllowAnonymous]
public async Task<IActionResult> GuestLogout()
{
var userName = HttpContext.User.Claims.First(c => c.Type == "preferred_username").Value;
var token = tokenManager.GenerateToken(userName);
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<IActionResult> 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]
[Route("GuestToken")]
public async Task<IActionResult> GetGuestToken([FromQuery] GetGuestToken request)
public async Task<IActionResult> GetGuestToken()
{
if (request.ClientId == null)
var user = await gameboardManager.ReadUser(User);
if (user == null)
{
var clientId = await gameboardManager.CreateGuestUser();
var token = tokenManager.GenerateToken(clientId);
return new JsonResult(new GetGuestTokenResponse(clientId, token));
// Create a guest user.
var newUser = Models.User.CreateGuestUser(Guid.NewGuid().ToString());
var success = await gameboardRepository.CreateUser(newUser);
if (!success)
{
return Conflict();
}
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));
var identity = newUser.CreateClaimsIdentity();
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
authenticationProps
);
user = newUser;
}
}
return new UnauthorizedResult();
var token = tokenCache.GenerateToken(user.Id.ToString());
return this.Ok(new GetGuestTokenResponse(user.Id, user.DisplayName, token));
}
}
}

View File

@@ -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..];
}
}
}

View File

@@ -1,6 +1,8 @@
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
@@ -10,6 +12,7 @@ namespace Gameboard.ShogiUI.Sockets.Extensions
private readonly RequestDelegate next;
private readonly ILogger logger;
public LogMiddleware(RequestDelegate next, ILoggerFactory factory)
{
this.next = next;
@@ -24,10 +27,14 @@ namespace Gameboard.ShogiUI.Sockets.Extensions
}
finally
{
logger.LogInformation("Request {method} {url} => {statusCode}",
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);
context.Response?.StatusCode,
Encoding.UTF8.GetString(stream.ToArray()));
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -1,21 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>5</AnalysisLevel>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Gameboard.Shogi.Api.ServiceModels" Version="2.10.0" />
<PackageReference Include="IdentityModel" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.AzureAD.UI" Version="5.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.2" />
<PackageReference Include="Microsoft.Identity.Web" Version="1.5.1" />
<PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="1.5.1" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="FluentValidation" Version="10.3.6" />
<PackageReference Include="IdentityModel" Version="6.0.0-preview.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.AzureAD.UI" Version="6.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.1" />
<PackageReference Include="Microsoft.Identity.Web" Version="1.21.1" />
<PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="1.21.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="NSwag.AspNetCore" Version="13.15.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Gameboard.ShogiUI.Sockets.ServiceModels\Gameboard.ShogiUI.Sockets.ServiceModels.csproj" />
<ProjectReference Include="..\PathFinding\PathFinding.csproj" />
</ItemGroup>

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<Controller_SelectedScaffolderID>ApiControllerEmptyScaffolder</Controller_SelectedScaffolderID>
<Controller_SelectedScaffolderCategoryPath>root/Controller</Controller_SelectedScaffolderCategoryPath>
<ActiveDebugProfile>AspShogiSockets</ActiveDebugProfile>
<ShowAllFiles>false</ShowAllFiles>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebuggerFlavor>ProjectDebugger</DebuggerFlavor>
</PropertyGroup>
</Project>

View File

@@ -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<CreateGameHandler> logger;
private readonly IGameboardRepository repository;
private readonly ISocketCommunicationManager communicationManager;
public CreateGameHandler(
ILogger<CreateGameHandler> 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<CreateGameRequest>(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);
}
}
}
}

View File

@@ -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
{
/// <summary>
/// Responsible for parsing json and handling the request.
/// </summary>
Task Handle(WebSocket socket, string json, string userName);
}
public delegate IActionHandler ActionHandlerResolver(ClientAction action);
}

View File

@@ -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<JoinByCodeHandler> logger;
private readonly IGameboardRepository repository;
private readonly ISocketCommunicationManager communicationManager;
private readonly ISocketConnectionManager communicationManager;
public JoinByCodeHandler(
ILogger<JoinByCodeHandler> 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<JoinByCode>(json);
var joinGameResponse = await repository.PostJoinPrivateSession(new PostJoinPrivateSession
{
PlayerName = userName,
JoinCode = request.JoinCode
});
//var request = JsonConvert.DeserializeObject<JoinByCode>(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);
//}
}
}
}

View File

@@ -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<JoinGameHandler> logger;
private readonly IGameboardRepository gameboardRepository;
private readonly ISocketCommunicationManager communicationManager;
public JoinGameHandler(
ILogger<JoinGameHandler> 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<JoinGameRequest>(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);
}
}
}

View File

@@ -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<ListGamesHandler> logger;
private readonly IGameboardRepository repository;
public ListGamesHandler(
ILogger<ListGamesHandler> 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<ListGamesRequest>(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<Game>()
};
var serialized = JsonConvert.SerializeObject(response);
logger.LogInformation("Socket Response \n{0}\n", new[] { serialized });
await socket.SendTextAsync(serialized);
}
}
}

View File

@@ -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<LoadGameHandler> logger;
private readonly IGameboardRepository gameboardRepository;
private readonly ISocketCommunicationManager communicationManager;
public LoadGameHandler(
ILogger<LoadGameHandler> 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<LoadGameRequest>(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);
}
}
}

View File

@@ -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<MoveHandler> logger;
private readonly IGameboardRepository gameboardRepository;
private readonly ISocketCommunicationManager communicationManager;
public MoveHandler(
ILogger<MoveHandler> 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<MoveRequest>(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;
}
);
}
}
}

View File

@@ -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<bool> AssignPlayer2ToSession(string sessionName, User user);
Task<User?> ReadUser(ClaimsPrincipal user);
Task<bool> CreateUser(ClaimsPrincipal user);
}
public class GameboardManager : IGameboardManager
{
private readonly IGameboardRepository repository;
public GameboardManager(IGameboardRepository repository)
{
this.repository = repository;
}
public Task<bool> 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<User?> ReadUser(ClaimsPrincipal principal)
{
var userId = principal.UserId();
if (!string.IsNullOrEmpty(userId))
{
return repository.ReadUser(userId);
}
return Task.FromResult<User?>(null);
}
public async Task<string> 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<bool> 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;
}
}
}

View File

@@ -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<string, WebSocket, string> 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<string, WebSocket> connections;
private readonly ConcurrentDictionary<string, List<string>> gameSeats;
private readonly ILogger<SocketCommunicationManager> logger;
private readonly ActionHandlerResolver handlerResolver;
public SocketCommunicationManager(
ILogger<SocketCommunicationManager> logger,
ActionHandlerResolver handlerResolver)
{
this.logger = logger;
this.handlerResolver = handlerResolver;
connections = new ConcurrentDictionary<string, WebSocket>();
gameSeats = new ConcurrentDictionary<string, List<string>>();
}
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<Request>(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);
}
}
/// <summary>
/// Unsubscribes the player from their current game, then subscribes to the new game.
/// </summary>
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<string> { 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<string, WebSocket, string> 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);
}
}
}
}

View File

@@ -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);
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);
}
/// <summary>
/// Retains all active socket connections and provides convenient methods for sending messages to clients.
/// </summary>
public class SocketConnectionManager : ISocketConnectionManager
{
private readonly ISocketCommunicationManager communicationManager;
private readonly ISocketTokenManager tokenManager;
/// <summary>Dictionary key is player name.</summary>
private readonly ConcurrentDictionary<string, WebSocket> connections;
/// <summary>Dictionary key is game name.</summary>
private readonly ConcurrentDictionary<string, Session> sessions;
private readonly ILogger<SocketConnectionManager> logger;
public SocketConnectionManager(ISocketCommunicationManager communicationManager, ISocketTokenManager tokenManager) : base()
public SocketConnectionManager(ILogger<SocketConnectionManager> logger)
{
this.communicationManager = communicationManager;
this.tokenManager = tokenManager;
this.logger = logger;
connections = new ConcurrentDictionary<string, WebSocket>();
sessions = new ConcurrentDictionary<string, Session>();
}
public async Task HandleSocketRequest(HttpContext context)
public void SubscribeToBroadcast(WebSocket socket, string playerName)
{
var hasToken = context.Request.Query.Keys.Contains("token");
if (hasToken)
connections.TryRemove(playerName, out var _);
connections.TryAdd(playerName, socket);
}
public void UnsubscribeFromBroadcastAndGames(string playerName)
{
var oneTimeToken = context.Request.Query["token"][0];
var tokenAsGuid = Guid.Parse(oneTimeToken);
var userName = tokenManager.GetUsername(tokenAsGuid);
if (!string.IsNullOrEmpty(userName))
connections.TryRemove(playerName, out _);
foreach (var kvp in sessions)
{
var socket = await context.WebSockets.AcceptWebSocketAsync();
await communicationManager.CommunicateWith(socket, userName);
return;
var sessionName = kvp.Key;
UnsubscribeFromGame(sessionName, playerName);
}
}
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
return;
/// <summary>
/// Unsubscribes the player from their current game, then subscribes to the new game.
/// </summary>
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<Task>(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<Task>(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;
//}
}
}

View File

@@ -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
{
/// <summary>
/// Key is userName or webSessionId
/// </summary>
private readonly ConcurrentDictionary<string, Guid> Tokens;
public SocketTokenCache()
{
Tokens = new ConcurrentDictionary<string, Guid>();
}
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;
}
/// <returns>User name associated to the guid or null.</returns>
public string? GetUsername(Guid guid)
{
var userName = Tokens.FirstOrDefault(kvp => kvp.Value == guid).Key;
if (userName != null)
{
Tokens.Remove(userName, out _);
}
return userName;
}
}
}

View File

@@ -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
{
/// <summary>
/// Key is userName
/// </summary>
private readonly Dictionary<string, Guid> Tokens;
public SocketTokenManager()
{
Tokens = new Dictionary<string, Guid>();
}
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;
}
/// <returns>User name associated to the guid or null.</returns>
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;
}
}
}

View File

@@ -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<GameboardTypes.PieceName> pieceFromCaptured = source.PieceFromCaptured switch
{
"B" => new FSharpOption<GameboardTypes.PieceName>(GameboardTypes.PieceName.Bishop),
"G" => new FSharpOption<GameboardTypes.PieceName>(GameboardTypes.PieceName.GoldenGeneral),
"K" => new FSharpOption<GameboardTypes.PieceName>(GameboardTypes.PieceName.King),
"k" => new FSharpOption<GameboardTypes.PieceName>(GameboardTypes.PieceName.Knight),
"L" => new FSharpOption<GameboardTypes.PieceName>(GameboardTypes.PieceName.Lance),
"P" => new FSharpOption<GameboardTypes.PieceName>(GameboardTypes.PieceName.Pawn),
"R" => new FSharpOption<GameboardTypes.PieceName>(GameboardTypes.PieceName.Rook),
"S" => new FSharpOption<GameboardTypes.PieceName>(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;
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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;
}
/// <summary>
/// Constructor to represent moving a piece on the Board to another position on the Board.
/// </summary>
/// <param name="fromNotation">Position the piece is being moved from.</param>
/// <param name="toNotation">Position the piece is being moved to.</param>
/// <param name="isPromotion">If the moving piece should be promoted.</param>
public Move(string fromNotation, string toNotation, bool isPromotion = false)
{
From = NotationHelper.FromBoardNotation(fromNotation);
To = NotationHelper.FromBoardNotation(toNotation);
IsPromotion = isPromotion;
}
/// <summary>
/// Constructor to represent moving a piece from the Hand to the Board.
/// </summary>
/// <param name="pieceFromHand">The piece being moved from the Hand to the Board.</param>
/// <param name="toNotation">Position the piece is being moved to.</param>
/// <param name="isPromotion">If the moving piece should be promoted.</param>
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)
};
}
}

View File

@@ -0,0 +1,95 @@
using PathFinding;
using System.Collections.Generic;
namespace Gameboard.ShogiUI.Sockets.Models
{
public static class MoveSets
{
public static readonly List<PathFinding.Move> 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<PathFinding.Move> 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<PathFinding.Move> 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<PathFinding.Move> 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<PathFinding.Move> Knight = new(2)
{
new PathFinding.Move(Direction.KnightLeft),
new PathFinding.Move(Direction.KnightRight)
};
public static readonly List<PathFinding.Move> Lance = new(1)
{
new PathFinding.Move(Direction.Up, Distance.MultiStep),
};
public static readonly List<PathFinding.Move> Pawn = new(1)
{
new PathFinding.Move(Direction.Up)
};
public static readonly List<PathFinding.Move> 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<PathFinding.Move> 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<PathFinding.Move> 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)
};
}
}

View File

@@ -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
};
}
}
}

View File

@@ -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<string, WebSocket> 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<string, WebSocket>();
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);
}
}

View File

@@ -0,0 +1,37 @@
namespace Gameboard.ShogiUI.Sockets.Models
{
/// <summary>
/// A representation of a Session without the board and game-rules.
/// </summary>
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);
}
}

View File

@@ -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
{
/// <summary>
/// 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
/// </summary>
public class Shogi
{
private delegate void MoveSetCallback(Piece piece, Vector2 position);
private readonly PathFinder2D<Piece> pathFinder;
private Shogi? validationBoard;
private Vector2 player1King;
private Vector2 player2King;
private List<Piece> Hand => WhoseTurn == WhichPerspective.Player1 ? Player1Hand : Player2Hand;
public List<Piece> Player1Hand { get; }
public List<Piece> Player2Hand { get; }
public CoordsToNotationCollection Board { get; } //TODO: Hide this being a getter method
public List<Move> 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<Move>(20);
Player1Hand = new List<Piece>();
Player2Hand = new List<Piece>();
pathFinder = new PathFinder2D<Piece>(Board, 9, 9);
player1King = new Vector2(4, 0);
player2King = new Vector2(4, 8);
Error = string.Empty;
InitializeBoardState();
}
public Shogi(IList<Move> 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<Piece>(Board, 9, 9);
MoveHistory = new List<Move>(toCopy.MoveHistory);
Player1Hand = new List<Piece>(toCopy.Player1Hand);
Player2Hand = new List<Piece>(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;
}
/// <summary>
/// Attempts a given move. Returns false if the move is illegal.
/// </summary>
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;
}
/// <returns>True if the move was successful.</returns>
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;
}
/// <returns>True if the move was successful.</returns>
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()
};
}
}
}

View File

@@ -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<string> Adjectives = new(new[] {
"Fortuitous", "Retractable", "Happy", "Habbitable", "Creative", "Fluffy", "Impervious", "Kingly"
});
public static readonly ReadOnlyCollection<string> 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<Claim>(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<Claim>(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
};
}
}

View File

@@ -0,0 +1,9 @@
namespace Gameboard.ShogiUI.Sockets.Models
{
public enum WhichLoginPlatform
{
Unknown,
Microsoft,
Guest
}
}

View File

@@ -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"
}
}
}

View File

@@ -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; }
/// <summary>
/// A dictionary where the key is a board-notation position, like D3.
/// </summary>
public Dictionary<string, Piece?> Board { get; set; }
public Piece[] Player1Hand { get; set; }
public Piece[] Player2Hand { get; set; }
/// <summary>
/// Move is null for first BoardState of a session - before anybody has made moves.
/// </summary>
public Move? Move { get; set; }
/// <summary>
/// Default constructor and setters are for deserialization.
/// </summary>
public BoardStateDocument() : base(WhichDocumentType.BoardState)
{
Name = string.Empty;
Board = new Dictionary<string, Piece?>(81, StringComparer.OrdinalIgnoreCase);
Player1Hand = Array.Empty<Piece>();
Player2Hand = Array.Empty<Piece>();
}
public BoardStateDocument(string sessionName, Models.Shogi shogi)
: base($"{sessionName}-{DateTime.Now:O}", WhichDocumentType.BoardState)
{
Name = sessionName;
Board = new Dictionary<string, Piece?>(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]);
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,16 @@
using System;
namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
{
internal class CouchFindResult<T>
{
public T[] docs;
public string warning;
public CouchFindResult()
{
docs = Array.Empty<T>();
warning = "";
}
}
}

View File

@@ -0,0 +1,28 @@
using System;
namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
{
public class CouchViewResult<T> where T : class
{
public int total_rows;
public int offset;
public CouchViewResultRow<T>[] rows;
public CouchViewResult()
{
rows = Array.Empty<CouchViewResultRow<T>>();
}
}
public class CouchViewResultRow<T>
{
public string id;
public T doc;
public CouchViewResultRow()
{
id = string.Empty;
doc = default!;
}
}
}

View File

@@ -0,0 +1,56 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Types;
using System.Numerics;
namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
{
public class Move
{
/// <summary>
/// A board coordinate, like A3 or G6. When null, look for PieceFromHand to exist.
/// </summary>
public string? From { get; set; }
public bool IsPromotion { get; set; }
/// <summary>
/// The piece placed from the player's hand.
/// </summary>
public WhichPiece? PieceFromHand { get; set; }
/// <summary>
/// A board coordinate, like A3 or G6.
/// </summary>
public string To { get; set; }
/// <summary>
/// Default constructor and setters are for deserialization.
/// </summary>
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);
}
}

View File

@@ -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; }
/// <summary>
/// Default constructor and setters are for deserialization.
/// </summary>
public Piece()
{
}
public Piece(Models.Piece piece)
{
IsPromoted = piece.IsPromoted;
Owner = piece.Owner;
WhichPiece = piece.WhichPiece;
}
public Models.Piece ToDomainModel() => new(WhichPiece, Owner, IsPromoted);
}
}

View File

@@ -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<BoardStateDocument> History { get; set; }
/// <summary>
/// Default constructor and setters are for deserialization.
/// </summary>
public SessionDocument() : base(WhichDocumentType.Session)
{
Name = string.Empty;
Player1Id = string.Empty;
Player2Id = string.Empty;
History = new List<BoardStateDocument>(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<BoardStateDocument>(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<BoardStateDocument>(0);
}
}
}

View File

@@ -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; }
/// <summary>
/// Constructor for JSON deserializing.
/// </summary>
public UserDocument() : base(WhichDocumentType.User)
{
DisplayName = string.Empty;
}
public UserDocument(
string id,
string displayName,
WhichLoginPlatform platform) : base(id, WhichDocumentType.User)
{
DisplayName = displayName;
Platform = platform;
}
}
}

View File

@@ -0,0 +1,9 @@
namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
{
public enum WhichDocumentType
{
User,
Session,
BoardState
}
}

View File

@@ -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<GetSessionResponse> GetGame(string gameName);
Task<GetSessionsResponse> GetGames();
Task<GetSessionsResponse> GetGames(string playerName);
Task<GetMovesResponse> GetMoves(string gameName);
Task<PostSessionResponse> PostSession(PostSession request);
Task<PostJoinPrivateSessionResponse> PostJoinPrivateSession(PostJoinPrivateSession request);
Task<PutJoinPublicSessionResponse> PutJoinPublicSession(string gameName, PutJoinPublicSession request);
Task PostMove(string gameName, PostMove request);
Task<PostJoinCodeResponse> PostJoinCode(string gameName, string userName);
Task<GetPlayerResponse> GetPlayer(string userName);
Task<HttpResponseMessage> PostPlayer(PostPlayer request);
Task<bool> CreateBoardState(Models.Session session);
Task<bool> CreateSession(Models.SessionMetadata session);
Task<bool> CreateUser(Models.User user);
Task<Collection<Models.SessionMetadata>> ReadSessionMetadatas();
Task<Models.Session?> ReadSession(string name);
Task<bool> UpdateSession(Models.SessionMetadata session);
Task<Models.SessionMetadata?> ReadSessionMetaData(string name);
Task<Models.User?> ReadUser(string userName);
}
public class GameboardRepository : IGameboardRepository
{
private readonly IAuthenticatedHttpClient client;
public GameboardRepository(IAuthenticatedHttpClient client)
/// <summary>
/// Returns session, board state, and user documents, grouped by session.
/// </summary>
private static readonly string View_SessionWithBoardState = "_design/session/_view/session-with-boardstate";
/// <summary>
/// Returns session and user documents, grouped by session.
/// </summary>
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<GameboardRepository> logger;
public GameboardRepository(IHttpClientFactory clientFactory, ILogger<GameboardRepository> logger)
{
this.client = client;
client = clientFactory.CreateClient("couchdb");
this.logger = logger;
}
public async Task<GetSessionsResponse> GetGames()
public async Task<Collection<Models.SessionMetadata>> ReadSessionMetadatas()
{
var response = await client.GetAsync("Sessions");
var json = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<GetSessionsResponse>(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<CouchViewResult<JObject>>(responseContent);
if (result != null)
{
var groupedBySession = result.rows.GroupBy(row => row.id);
var sessions = new List<Models.SessionMetadata>(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<SessionDocument>();
var player1Doc = group.Skip(1).FirstOrDefault()?.doc.ToObject<UserDocument>();
var player2Doc = group.Skip(2).FirstOrDefault()?.doc.ToObject<UserDocument>();
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<Models.SessionMetadata>(sessions);
}
return new Collection<Models.SessionMetadata>(Array.Empty<Models.SessionMetadata>());
}
public async Task<GetSessionsResponse> GetGames(string playerName)
public async Task<Models.Session?> ReadSession(string name)
{
var uri = $"Sessions/{playerName}";
var response = await client.GetAsync(Uri.EscapeUriString(uri));
var json = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<GetSessionsResponse>(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<CouchViewResult<JObject>>(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<SessionDocument>();
var player1Doc = group[1].doc.ToObject<UserDocument>();
var group2DocumentType = group[2].doc.Property(nameof(UserDocument.DocumentType).ToCamelCase())?.Value.Value<string>();
var player2Doc = group2DocumentType == WhichDocumentType.User.ToString()
? group[2].doc.ToObject<UserDocument>()
: 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<BoardStateDocument>())
.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<GetSessionResponse> GetGame(string gameName)
public async Task<Models.SessionMetadata?> ReadSessionMetaData(string name)
{
var uri = $"Session/{gameName}";
var response = await client.GetAsync(Uri.EscapeUriString(uri));
var json = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<GetSessionResponse>(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<CouchViewResult<JObject>>(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<SessionDocument>();
var player1Doc = group[1].doc.ToObject<UserDocument>();
var group2DocumentType = group[2].doc.Property(nameof(UserDocument.DocumentType).ToCamelCase())?.Value.Value<string>();
var player2Doc = group2DocumentType == WhichDocumentType.User.ToString()
? group[2].doc.ToObject<UserDocument>()
: 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)
/// <summary>
/// Saves a snapshot of board state and the most recent move.
/// </summary>
public async Task<bool> 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<PostSessionResponse> PostSession(PostSession request)
public async Task<bool> 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<PostSessionResponse>(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<PutJoinPublicSessionResponse> PutJoinPublicSession(string gameName, PutJoinPublicSession request)
public async Task<bool> 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<PutJoinPublicSessionResponse>(json);
// GET existing session to get revisionId.
var readResponse = await client.GetAsync(session.Name);
if (!readResponse.IsSuccessStatusCode) return false;
var sessionDocument = JsonConvert.DeserializeObject<SessionDocument>(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<bool> 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<PutJoinPublicSessionResponse>(json).JoinSucceeded;
//}
//public async Task<string> 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<PostJoinPrivateSessionResponse>(json);
// if (deserialized.JoinSucceeded)
// {
// return deserialized.SessionName;
// }
// return null;
//}
//public async Task<List<Move>> 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<Move>();
// }
// var response = JsonConvert.DeserializeObject<GetMovesResponse>(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<string> 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<PostJoinCodeResponse>(json).JoinCode;
return string.Empty;
}
public async Task<PostJoinPrivateSessionResponse> PostJoinPrivateSession(PostJoinPrivateSession request)
public async Task<Models.User?> 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<PostJoinPrivateSessionResponse>(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<CouchViewResult<UserDocument>>(responseContent);
if (result != null && result.rows.Length > 0)
{
return new Models.User(result.rows[0].doc);
}
public async Task<GetMovesResponse> GetMoves(string gameName)
{
var uri = $"Session/{gameName}/Moves";
var response = await client.GetAsync(Uri.EscapeUriString(uri));
var json = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<GetMovesResponse>(json);
return null;
}
public async Task PostMove(string gameName, PostMove request)
public async Task<bool> CreateUser(Models.User user)
{
var uri = $"Session/{gameName}/Move";
var content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json");
await client.PostAsync(Uri.EscapeUriString(uri), content);
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<PostJoinCodeResponse> 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<PostJoinCodeResponse>(json);
}
public async Task<GetPlayerResponse> 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<GetPlayerResponse>(json);
}
public async Task<HttpResponseMessage> PostPlayer(PostPlayer request)
{
var content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json");
return await client.PostAsync("Player", content);
}
}
}

View File

@@ -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<string> CreateGuestUser();
Task<bool> 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<string> 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<bool> IsPlayer1(string sessionName, string playerName)
{
var session = await repository.GetGame(sessionName);
return session?.Session.Player1 == playerName;
}
public async Task<string> 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);
}
}

View File

@@ -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<HttpResponseMessage> DeleteAsync(string requestUri);
Task<HttpResponseMessage> GetAsync(string requestUri);
Task<HttpResponseMessage> PostAsync(string requestUri, HttpContent content);
Task<HttpResponseMessage> PutAsync(string requestUri, HttpContent content);
}
public class AuthenticatedHttpClient : HttpClient, IAuthenticatedHttpClient
{
private readonly ILogger<AuthenticatedHttpClient> logger;
private readonly string identityServerUrl;
private TokenResponse tokenResponse;
private readonly string clientId;
private readonly string clientSecret;
public AuthenticatedHttpClient(ILogger<AuthenticatedHttpClient> 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<HttpResponseMessage> 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<HttpResponseMessage> 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<HttpResponseMessage> 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<HttpResponseMessage> 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;
}
}
}

View File

@@ -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<JoinByCodeRequest>
{
public JoinByCodeRequestValidator()
{
RuleFor(_ => _.Action).Equal(ClientAction.JoinByCode);
RuleFor(_ => _.JoinCode).NotEmpty();
}
}
}

View File

@@ -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<JoinGameRequest>
{
public JoinGameRequestValidator()
{
RuleFor(_ => _.Action).Equal(ClientAction.JoinGame);
RuleFor(_ => _.GameName).NotEmpty();
}
}
}

View File

@@ -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);
}
/// <summary>
/// Services a single websocket connection. Authenticates the socket connection, accepts messages, and sends messages.
/// </summary>
public class SocketService : ISocketService
{
private readonly ILogger<SocketService> logger;
private readonly ISocketConnectionManager communicationManager;
private readonly IGameboardRepository gameboardRepository;
private readonly IGameboardManager gameboardManager;
private readonly ISocketTokenCache tokenManager;
private readonly IJoinByCodeHandler joinByCodeHandler;
private readonly IValidator<JoinByCodeRequest> joinByCodeRequestValidator;
private readonly IValidator<JoinGameRequest> joinGameRequestValidator;
public SocketService(
ILogger<SocketService> logger,
ISocketConnectionManager communicationManager,
IGameboardRepository gameboardRepository,
IGameboardManager gameboardManager,
ISocketTokenCache tokenManager,
IJoinByCodeHandler joinByCodeHandler,
IValidator<JoinByCodeRequest> joinByCodeRequestValidator,
IValidator<JoinGameRequest> 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<Request>(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<JoinByCodeRequest>(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<bool> ValidateRequestAndReplyIfInvalid<TRequest>(WebSocket socket, IValidator<TRequest> 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;
}
}
}

View File

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

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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
{
/// <summary>
/// Standardizes the claims from third party issuers. Also registers new msal users in the database.
/// </summary>
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<ClaimsPrincipal> 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;
}
}
}

View File

@@ -1,22 +1,32 @@
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
{
@@ -32,56 +42,100 @@ namespace Gameboard.ShogiUI.Sockets
// 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<CreateGameHandler>();
services.AddSingleton<JoinByCodeHandler>();
services.AddSingleton<JoinGameHandler>();
services.AddSingleton<ListGamesHandler>();
services.AddSingleton<LoadGameHandler>();
services.AddSingleton<MoveHandler>();
// Managers
services.AddSingleton<ISocketCommunicationManager, SocketCommunicationManager>();
services.AddSingleton<ISocketTokenManager, SocketTokenManager>();
services.AddSingleton<IJoinByCodeHandler, JoinByCodeHandler>();
services.AddSingleton<ISocketConnectionManager, SocketConnectionManager>();
services.AddScoped<IGameboardRepositoryManager, GameboardRepositoryManager>();
services.AddSingleton<ActionHandlerResolver>(sp => action =>
services.AddSingleton<ISocketTokenCache, SocketTokenCache>();
services.AddSingleton<IGameboardManager, GameboardManager>();
services.AddSingleton<IValidator<JoinByCodeRequest>, JoinByCodeRequestValidator>();
services.AddSingleton<IValidator<JoinGameRequest>, JoinGameRequestValidator>();
services.AddSingleton<ISocketService, SocketService>();
services.AddTransient<IGameboardRepository, GameboardRepository>();
services.AddSingleton<IClaimsTransformation, ShogiUserClaimsTransformer>();
services.AddHttpClient("couchdb", c =>
{
return action switch
var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:admin"));
c.DefaultRequestHeaders.Add("Accept", "application/json");
c.DefaultRequestHeaders.Add("Authorization", $"Basic {base64}");
var baseUrl = $"{Configuration["AppSettings:CouchDB:Url"]}/{Configuration["AppSettings:CouchDB:Database"]}/";
c.BaseAddress = new Uri(baseUrl);
});
services
.AddControllers()
.AddNewtonsoftJson(options =>
{
ClientAction.ListGames => sp.GetService<ListGamesHandler>(),
ClientAction.CreateGame => sp.GetService<CreateGameHandler>(),
ClientAction.JoinGame => sp.GetService<JoinGameHandler>(),
ClientAction.JoinByCode => sp.GetService<JoinByCodeHandler>(),
ClientAction.LoadGame => sp.GetService<LoadGameHandler>(),
ClientAction.Move => sp.GetService<MoveHandler>(),
_ => throw new KeyNotFoundException($"Unable to resolve {nameof(IActionHandler)} for {nameof(ClientAction)} {action}"),
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.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.AddSwaggerDocument(config =>
{
//config.AddSecurity("bearer", Enumerable.Empty<string>(), new NSwag.OpenApiSecurityScheme
//{
// Type = NSwag.OpenApiSecuritySchemeType.OAuth2,
// Flow = NSwag.OpenApiOAuth2Flow.Implicit,
// Flows = new NSwag.OpenApiOAuthFlows
// {
// Implicit = new NSwag.OpenApiOAuthFlow
// {
// Scopes =
// }
// }
//});
// 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<string, string> {
{ "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";
};
});
// Repositories
services.AddTransient<IGameboardRepository, GameboardRepository>();
services.AddSingleton<IAuthenticatedHttpClient, AuthenticatedHttpClient>();
services.AddControllers();
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;
});
// Remove default HttpClient logging.
services.RemoveAll<IHttpMessageHandlerBuilderFilter>();
}
// 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)
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ISocketService socketConnectionManager)
{
var origins = new[] {
"http://localhost:3000", "https://localhost:3000",
@@ -95,6 +149,18 @@ namespace Gameboard.ShogiUI.Sockets
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
{
@@ -102,16 +168,20 @@ namespace Gameboard.ShogiUI.Sockets
}
app
.UseRequestResponseLogging()
.UseCors(
opt => opt
.WithOrigins(origins)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
)
.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 =>
{
@@ -119,12 +189,7 @@ namespace Gameboard.ShogiUI.Sockets
})
.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)
if (context.WebSockets.IsWebSocketRequest)
{
await socketConnectionManager.HandleSocketRequest(context);
}
@@ -139,10 +204,13 @@ namespace Gameboard.ShogiUI.Sockets
Formatting = Formatting.Indented,
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new CamelCaseNamingStrategy(),
NamingStrategy = new CamelCaseNamingStrategy
{
ProcessDictionaryKeys = true
}
},
Converters = new[] { new StringEnumConverter() },
NullValueHandling = NullValueHandling.Ignore
NullValueHandling = NullValueHandling.Ignore,
};
}
}

View File

@@ -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<string, Piece?>, IPlanarCollection<Piece>
{
public delegate void ForEachDelegate(Piece element, Vector2 position);
public CoordsToNotationCollection() : base(81, StringComparer.OrdinalIgnoreCase)
{
}
public CoordsToNotationCollection(Dictionary<string, Piece?> 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);
}
}
}
}
}

View File

@@ -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 = @"(?<file>[a-iA-I])(?<rank>[1-9])";
private static readonly char A = 'A';
public static string ToBoardNotation(Vector2 vector)
{
return ToBoardNotation((int)vector.X, (int)vector.Y);
}
public static string ToBoardNotation(int x, int y)
{
var file = (char)(x + A);
var rank = y + 1;
return $"{file}{rank}";
}
public static Vector2 FromBoardNotation(string notation)
{
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}");
}
}
}

View File

@@ -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": "*"
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoFixture" Version="4.17.0" />
<PackageReference Include="FluentAssertions" Version="6.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0-preview-20211130-02" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.8" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.8" />
<PackageReference Include="xunit" Version="2.4.2-pre.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Gameboard.ShogiUI.Sockets.ServiceModels\Gameboard.ShogiUI.Sockets.ServiceModels.csproj" />
<ProjectReference Include="..\Gameboard.ShogiUI.Sockets\Gameboard.ShogiUI.Sockets.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<IPlanarElement>.IsPathable(
new Vector2(2, 2),
new Vector2(7, 7),
new Vector2(1, 1)
);
result.Should().BeTrue();
result = PathFinder2D<IPlanarElement>.IsPathable(
new Vector2(2, 2),
new Vector2(7, 7),
new Vector2(0, 0)
);
result.Should().BeFalse();
result = PathFinder2D<IPlanarElement>.IsPathable(
new Vector2(2, 2),
new Vector2(7, 7),
new Vector2(-1, 1)
);
result.Should().BeFalse();
}
}
}

View File

@@ -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<SimpleElement>();
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);
}
}
}
}

View File

@@ -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<SimpleElement>
{
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<SimpleElement> GetEnumerator()
{
foreach (var e in array)
yield return e;
}
//IEnumerator IEnumerable.GetEnumerator()
//{
// return array.GetEnumerator();
//}
}
}

View File

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

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