squash a bunch of commits

This commit is contained in:
2022-10-30 12:03:16 -05:00
parent 09b72c1858
commit 93027e8c57
222 changed files with 6157 additions and 3201 deletions

2
.gitignore vendored
View File

@@ -53,3 +53,5 @@ Thumbs.db
bin bin
obj obj
*.user *.user
/Shogi.Database/Shogi.Database.dbmdl
/Shogi.Database/Shogi.Database.jfm

View File

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

@@ -1,11 +0,0 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Types;
using System.Collections.Generic;
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api
{
public class GetSessionsResponse
{
public IList<SessionMetadata> PlayerHasJoinedSessions { get; set; }
public IList<SessionMetadata> AllOtherSessions { get; set; }
}
}

View File

@@ -1,14 +0,0 @@
using System;
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api
{
public class GetTokenResponse
{
public Guid OneTimeToken { get; }
public GetTokenResponse(Guid token)
{
OneTimeToken = token;
}
}
}

View File

@@ -1,20 +0,0 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api
{
public class PostGameInvitation
{
public string SessionName { get; set; }
}
public class PostGuestGameInvitation
{
public string GuestId { get; set; }
public string SessionName { get; set; }
}
public class PostGameInvitationResponse
{
public string Code { get; }
public PostGameInvitationResponse(string code)
{
Code = code;
}
}
}

View File

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

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

View File

@@ -1,20 +0,0 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Types;
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket
{
public class CreateGameResponse : ISocketResponse
{
public string Action { get; }
public SessionMetadata Game { get; set; }
/// <summary>
/// The player who created the game.
/// </summary>
public string PlayerName { get; set; }
public CreateGameResponse()
{
Action = ClientAction.CreateGame.ToString();
}
}
}

View File

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

View File

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

View File

@@ -1,41 +0,0 @@
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 : ISocketResponse
{
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, ISocketResponse
{
public JoinByCodeResponse()
{
Action = ClientAction.JoinByCode.ToString();
}
}
}

View File

@@ -1,19 +0,0 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Types;
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket
{
public class MoveResponse : ISocketResponse
{
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,10 +0,0 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types
{
public enum ClientAction
{
CreateGame,
JoinGame,
JoinByCode,
Move
}
}

View File

@@ -1,12 +0,0 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types
{
public class SessionMetadata
{
public string Name { get; set; }
public string Player1 { get; set; }
public string? Player2 { get; set; }
public bool IsPrivate { get; set; }
public string[] Players => string.IsNullOrEmpty(Player2) ? new[] { Player1 } : new[] { Player1, Player2 };
}
}

View File

@@ -1,8 +0,0 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types
{
public enum WhichPlayer
{
Player1,
Player2
}
}

View File

@@ -1,55 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# 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}") = "Shogi.Domain", "Shogi.Domain\Shogi.Domain.csproj", "{0211B1E4-20F0-4058-AAC4-3845D19910AF}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shogi.Domain.UnitTests", "Shogi.Domain.UnitTests\Shogi.Domain.UnitTests.csproj", "{F256989E-B6AF-4731-9DB4-88991C40B2CE}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shogi.AcceptanceTests", "Shogi.AcceptanceTests\Shogi.AcceptanceTests.csproj", "{F4AB1C7C-CDE5-465D-81A1-DAF1D97225BA}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{4FF35F9D-E525-46CF-A8A6-A147FE50AD68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4FF35F9D-E525-46CF-A8A6-A147FE50AD68}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4FF35F9D-E525-46CF-A8A6-A147FE50AD68}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4FF35F9D-E525-46CF-A8A6-A147FE50AD68}.Release|Any CPU.Build.0 = Release|Any CPU
{FE775DE4-50F0-4C5D-AD2B-01320B1E7086}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{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
{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
{F4AB1C7C-CDE5-465D-81A1-DAF1D97225BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F4AB1C7C-CDE5-465D-81A1-DAF1D97225BA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F4AB1C7C-CDE5-465D-81A1-DAF1D97225BA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F4AB1C7C-CDE5-465D-81A1-DAF1D97225BA}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{F256989E-B6AF-4731-9DB4-88991C40B2CE} = {F35A56FB-B8D8-4CB7-ABF6-D40049C9B42E}
{F4AB1C7C-CDE5-465D-81A1-DAF1D97225BA} = {F35A56FB-B8D8-4CB7-ABF6-D40049C9B42E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {1D0B04F2-0DA1-4CB4-A82A-5A1C3B52ACEB}
EndGlobalSection
EndGlobal

View File

@@ -1,253 +0,0 @@
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;
namespace Gameboard.ShogiUI.Sockets.Controllers
{
[ApiController]
[Route("[controller]")]
[Authorize]
public class GameController : ControllerBase
{
private readonly IGameboardManager gameboardManager;
private readonly IGameboardRepository gameboardRepository;
private readonly ISocketConnectionManager communicationManager;
private readonly IModelMapper mapper;
public GameController(
IGameboardRepository repository,
IGameboardManager manager,
ISocketConnectionManager communicationManager,
IModelMapper mapper)
{
gameboardManager = manager;
gameboardRepository = repository;
this.communicationManager = communicationManager;
this.mapper = mapper;
}
[HttpPost("JoinCode")]
public async Task<IActionResult> PostGameInvitation([FromBody] PostGameInvitation request)
{
//var isPlayer1 = await gameboardManager.IsPlayer1(request.SessionName, userName);
//if (isPlayer1)
//{
// var code = await gameboardRepository.PostJoinCode(request.SessionName, userName);
// return new CreatedResult("", new PostGameInvitationResponse(code));
//}
//else
//{
return new UnauthorizedResult();
//}
}
[AllowAnonymous]
[HttpPost("GuestJoinCode")]
public async Task<IActionResult> PostGuestGameInvitation([FromBody] PostGuestGameInvitation request)
{
//var isGuest = gameboardManager.IsGuest(request.GuestId);
//var isPlayer1 = gameboardManager.IsPlayer1(request.SessionName, request.GuestId);
//if (isGuest && await isPlayer1)
//{
// var code = await gameboardRepository.PostJoinCode(request.SessionName, request.GuestId);
// return new CreatedResult("", new PostGameInvitationResponse(code));
//}
//else
//{
return new UnauthorizedResult();
//}
}
[HttpPost("{gameName}/Move")]
public async Task<IActionResult> PostMove([FromRoute] string gameName, [FromBody] PostMove request)
{
var user = await gameboardManager.ReadUser(User);
var session = await gameboardRepository.ReadSession(gameName);
if (session == null)
{
return NotFound();
}
if (user == null || (session.Player1Name != user.Id && session.Player2Name != user.Id))
{
return Forbid("User is not seated at this game.");
}
try
{
var move = request.Move;
if (move.PieceFromCaptured.HasValue)
session.Move(mapper.Map(move.PieceFromCaptured.Value), move.To);
else if (!string.IsNullOrWhiteSpace(move.From))
session.Move(move.From, move.To, move.IsPromotion);
await gameboardRepository.CreateBoardState(session);
await communicationManager.BroadcastToPlayers(
new MoveResponse
{
GameName = session.Name,
PlayerName = user.Id
},
session.Player1Name,
session.Player2Name);
return Ok();
}
catch (InvalidOperationException ex)
{
return Conflict(ex.Message);
}
}
// 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 Shogi.Domain.SessionMetadata(request.Name, request.IsPrivate, user.Id);
await gameboardRepository.CreateSession(session);
await communicationManager.BroadcastToAll(new CreateGameResponse
{
Game = mapper.Map(session),
PlayerName = user.Id
});
return Ok();
}
/// <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 = session.Player2Name == user.Id
? WhichPlayer.Player2
: WhichPlayer.Player1;
var response = new Session
{
BoardState = new BoardState
{
Board = mapper.Map(session.BoardState),
Player1Hand = session.Player1Hand.Select(mapper.Map).ToList(),
Player2Hand = session.Player2Hand.Select(mapper.Map).ToList(),
PlayerInCheck = mapper.Map(session.InCheck)
},
GameName = session.Name,
Player1 = session.Player1Name,
Player2 = session.Player2Name
};
return Ok(response);
}
[HttpGet]
public async Task<ActionResult<GetSessionsResponse>> GetSessions()
{
var user = await ReadUserOrThrow();
var sessions = await gameboardRepository.ReadSessionMetadatas();
var sessionsJoinedByUser = sessions
.Where(s => s.IsSeated(user.Id))
.Select(s => mapper.Map(s))
.ToList();
var sessionsNotJoinedByUser = sessions
.Where(s => !s.IsSeated(user.Id))
.Select(s => mapper.Map(s))
.ToList();
return Ok(new GetSessionsResponse
{
PlayerHasJoinedSessions = sessionsJoinedByUser,
AllOtherSessions = 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.Id);
await gameboardRepository.UpdateSession(session);
var opponentName = user.Id == session.Player1
? session.Player2!
: session.Player1;
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 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,102 +0,0 @@
using Gameboard.ShogiUI.Sockets.Extensions;
using Gameboard.ShogiUI.Sockets.Managers;
using Gameboard.ShogiUI.Sockets.Repositories;
using Gameboard.ShogiUI.Sockets.ServiceModels.Api;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace Gameboard.ShogiUI.Sockets.Controllers
{
[ApiController]
[Route("[controller]")]
[Authorize]
public class SocketController : ControllerBase
{
private readonly ISocketTokenCache tokenCache;
private readonly IGameboardManager gameboardManager;
private readonly IGameboardRepository gameboardRepository;
private readonly ISocketConnectionManager connectionManager;
private readonly AuthenticationProperties authenticationProps;
public SocketController(
ILogger<SocketController> logger,
ISocketTokenCache tokenCache,
IGameboardManager gameboardManager,
IGameboardRepository gameboardRepository,
ISocketConnectionManager connectionManager)
{
this.tokenCache = tokenCache;
this.gameboardManager = gameboardManager;
this.gameboardRepository = gameboardRepository;
this.connectionManager = connectionManager;
authenticationProps = new AuthenticationProperties
{
AllowRefresh = true,
IsPersistent = true
};
}
[HttpGet("GuestLogout")]
[AllowAnonymous]
public async Task<IActionResult> GuestLogout()
{
var signoutTask = HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var userId = User?.UserId();
if (!string.IsNullOrEmpty(userId))
{
connectionManager.Unsubscribe(userId);
}
await signoutTask;
return Ok();
}
[HttpGet("Token")]
public async Task<IActionResult> GetToken()
{
var user = await gameboardManager.ReadUser(User);
if (user == null)
{
await gameboardManager.CreateUser(User);
user = await gameboardManager.ReadUser(User);
}
if (user == null)
{
return Unauthorized();
}
var token = tokenCache.GenerateToken(user.Id);
return new JsonResult(new GetTokenResponse(token));
}
[HttpGet("GuestToken")]
[AllowAnonymous]
public async Task<IActionResult> GetGuestToken()
{
var user = await gameboardManager.ReadUser(User);
if (user == null)
{
// Create a guest user.
var newUser = Models.User.CreateGuestUser(Guid.NewGuid().ToString());
await gameboardRepository.CreateUser(newUser);
var identity = newUser.CreateClaimsIdentity();
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
authenticationProps
);
user = newUser;
}
var token = tokenCache.GenerateToken(user.Id.ToString());
return this.Ok(new GetGuestTokenResponse(user.Id, user.DisplayName, token));
}
}
}

View File

@@ -1,29 +0,0 @@
using System.Security.Claims;
namespace Gameboard.ShogiUI.Sockets.Extensions
{
public static class Extensions
{
private static readonly string MsalUsernameClaim = "preferred_username";
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 IsMicrosoft(this ClaimsPrincipal self)
{
return self.HasClaim(c => c.Type == MsalUsernameClaim);
}
public static string ChangeFirstCharacterToLowerCase(this string self)
{
return char.ToLowerInvariant(self[0]) + self[1..];
}
}
}

View File

@@ -1,64 +0,0 @@
using Gameboard.ShogiUI.Sockets.Repositories;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket;
using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers
{
public interface IJoinByCodeHandler
{
Task Handle(JoinByCodeRequest request, string userName);
}
public class JoinByCodeHandler : IJoinByCodeHandler
{
private readonly IGameboardRepository repository;
private readonly ISocketConnectionManager communicationManager;
public JoinByCodeHandler(
ISocketConnectionManager communicationManager,
IGameboardRepository repository)
{
this.repository = repository;
this.communicationManager = communicationManager;
}
public async Task Handle(JoinByCodeRequest request, string userName)
{
//var request = JsonConvert.DeserializeObject<JoinByCode>(json);
//var sessionName = await repository.PostJoinPrivateSession(new PostJoinPrivateSession
//{
// PlayerName = userName,
// JoinCode = request.JoinCode
//});
//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);
// // The player joining sees the JoinByCode occur.
// response = new JoinGameResponse(ClientAction.JoinByCode)
// {
// PlayerName = userName,
// GameName = sessionName
// };
// await communicationManager.BroadcastToPlayers(response, userName);
//}
}
}
}

View File

@@ -1,74 +0,0 @@
using Gameboard.ShogiUI.Sockets.Extensions;
using Gameboard.ShogiUI.Sockets.Models;
using Gameboard.ShogiUI.Sockets.Repositories;
using System;
using System.Security.Claims;
using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.Managers
{
public interface IGameboardManager
{
Task AssignPlayer2ToSession(string sessionName, User user);
Task<User?> ReadUser(ClaimsPrincipal user);
Task<User?> CreateUser(ClaimsPrincipal user);
}
public class GameboardManager : IGameboardManager
{
private readonly IGameboardRepository repository;
public GameboardManager(IGameboardRepository repository)
{
this.repository = repository;
}
public async Task<User> CreateUser(ClaimsPrincipal principal)
{
var id = principal.UserId();
if (string.IsNullOrEmpty(id))
{
throw new InvalidOperationException("Cannot create user from given claims.");
}
var user = principal.IsMicrosoft()
? User.CreateMsalUser(id)
: User.CreateGuestUser(id);
await repository.CreateUser(user);
return 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 AssignPlayer2ToSession(string sessionName, User user)
{
var session = await repository.ReadSessionMetaData(sessionName);
if (session != null && !session.IsPrivate && session.Player2 == null)
{
session.SetPlayer2(user.Id);
await repository.UpdateSession(session);
}
}
}
}

View File

@@ -1,98 +0,0 @@
using Gameboard.ShogiUI.Sockets.Extensions;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System;
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 BroadcastToAll(ISocketResponse response);
void Subscribe(WebSocket socket, string playerName);
void Unsubscribe(string playerName);
Task BroadcastToPlayers(ISocketResponse response, params string?[] playerNames);
}
/// <summary>
/// Retains all active socket connections and provides convenient methods for sending messages to clients.
/// </summary>
public class SocketConnectionManager : ISocketConnectionManager
{
/// <summary>Dictionary key is player name.</summary>
private readonly ConcurrentDictionary<string, WebSocket> connections;
/// <summary>Dictionary key is game name.</summary>
private readonly ILogger<SocketConnectionManager> logger;
public SocketConnectionManager(ILogger<SocketConnectionManager> logger)
{
this.logger = logger;
connections = new ConcurrentDictionary<string, WebSocket>();
}
public void Subscribe(WebSocket socket, string playerName)
{
connections.TryRemove(playerName, out var _);
connections.TryAdd(playerName, socket);
}
public void Unsubscribe(string playerName)
{
connections.TryRemove(playerName, out _);
}
public async Task BroadcastToPlayers(ISocketResponse 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(ISocketResponse 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)
{
logger.LogInformation("Tried sending a message to socket connection for user [{user}], but found the connection has closed.", kvp.Key);
Unsubscribe(kvp.Key);
}
catch
{
logger.LogInformation("Tried sending a message to socket connection for user [{user}], but found the connection has closed.", kvp.Key);
Unsubscribe(kvp.Key);
}
}
try
{
var task = Task.WhenAll(tasks);
return task;
}
catch
{
Console.WriteLine("Yo");
}
return Task.FromResult(0);
}
}
}

View File

@@ -1,83 +0,0 @@
using Gameboard.ShogiUI.Sockets.Repositories.CouchModels;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
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"),
};
return new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
}
else
{
var claims = new List<Claim>(3)
{
new Claim(ClaimTypes.NameIdentifier, Id),
new Claim(ClaimTypes.Name, DisplayName),
};
return new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
}
}
public ServiceModels.Types.User ToServiceModel() => new()
{
Id = Id,
Name = DisplayName
};
}
}

View File

@@ -1,310 +0,0 @@
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 Shogi.Domain;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.Repositories
{
public interface IGameboardRepository
{
Task CreateBoardState(Session session);
Task CreateSession(SessionMetadata session);
Task CreateUser(Models.User user);
Task<Collection<SessionMetadata>> ReadSessionMetadatas();
Task<Session?> ReadSession(string name);
Task UpdateSession(SessionMetadata session);
Task<SessionMetadata?> ReadSessionMetaData(string name);
Task<Models.User?> ReadUser(string userName);
}
public class GameboardRepository : IGameboardRepository
{
/// <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)
{
client = clientFactory.CreateClient("couchdb");
this.logger = logger;
}
public async Task<Collection<SessionMetadata>> ReadSessionMetadatas()
{
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<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 player1 = group.Skip(1).FirstOrDefault()?.doc.ToObject<UserDocument>();
var player2Doc = group.Skip(2).FirstOrDefault()?.doc;
if (session != null && player1 != null && player2Doc != null)
{
var player2 = IsUserDocument(player2Doc)
? new Models.User(player2Doc.ToObject<UserDocument>()!)
: null;
sessions.Add(new SessionMetadata(session.Name, session.IsPrivate, player1.Id, player2?.Id));
}
}
return new Collection<SessionMetadata>(sessions);
}
return new Collection<SessionMetadata>(Array.Empty<SessionMetadata>());
}
private static bool IsUserDocument(JObject player2Doc)
{
return player2Doc?.SelectToken(nameof(CouchDocument.DocumentType))?.Value<WhichDocumentType>() == WhichDocumentType.User;
}
public async Task<Session?> ReadSession(string name)
{
static Shogi.Domain.Pieces.Piece? MapPiece(Piece? piece)
{
return piece == null
? null
: Shogi.Domain.Pieces.Piece.Create(piece.WhichPiece, piece.Owner, piece.IsPromoted);
}
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 multiple elements.
* 0) The session metadata.
* 1) User documents of Player1.
* 2) User documents of Player1.
* 2.a) If the Player2 document doesn't exist, CouchDB will return the SessionDocument instead :(
* Everything Else) Snapshots of the boardstate after every player move.
*/
var session = group[0].doc.ToObject<SessionDocument>();
var player1 = group[1].doc.ToObject<UserDocument>();
var player2Doc = group[2].doc;
var boardState = group.Last().doc.ToObject<BoardStateDocument>();
if (session != null && player1 != null && boardState != null)
{
var player2 = IsUserDocument(player2Doc)
? new Models.User(player2Doc.ToObject<UserDocument>()!)
: null;
var metaData = new SessionMetadata(session.Name, session.IsPrivate, player1.Id, player2?.Id);
var shogiBoardState = new BoardState(boardState.Board.ToDictionary(kvp => kvp.Key, kvp => MapPiece(kvp.Value)));
return new Session(shogiBoardState, metaData);
}
}
return null;
}
public async Task<SessionMetadata?> ReadSessionMetaData(string name)
{
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 player1 = group[1].doc.ToObject<UserDocument>();
var player2Doc = group[2].doc;
if (session != null && player1 != null)
{
var player2 = IsUserDocument(player2Doc)
? new Models.User(player2Doc.ToObject<UserDocument>()!)
: null;
return new SessionMetadata(session.Name, session.IsPrivate, player1.Id, player2?.Id);
}
}
return null;
}
/// <summary>
/// Saves a snapshot of board state and the most recent move.
/// </summary>
public async Task CreateBoardState(Session session)
{
var boardStateDocument = new BoardStateDocument(session.Name, session);
var content = new StringContent(JsonConvert.SerializeObject(boardStateDocument), Encoding.UTF8, ApplicationJson);
var response = await client.PostAsync(string.Empty, content);
response.EnsureSuccessStatusCode();
}
public async Task CreateSession(SessionMetadata session)
{
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 Session());
var boardStateContent = new StringContent(JsonConvert.SerializeObject(boardStateDocument), Encoding.UTF8, ApplicationJson);
if ((await postSessionDocumentTask).IsSuccessStatusCode)
{
var response = await client.PostAsync(string.Empty, boardStateContent);
response.EnsureSuccessStatusCode();
}
}
public async Task UpdateSession(SessionMetadata session)
{
// GET existing session to get revisionId.
var readResponse = await client.GetAsync(session.Name);
readResponse.EnsureSuccessStatusCode();
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);
response.EnsureSuccessStatusCode();
}
//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<Models.User?> ReadUser(string id)
{
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);
}
return null;
}
public async Task CreateUser(Models.User user)
{
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);
response.EnsureSuccessStatusCode();
}
public void ReadMoveHistory()
{
//TODO: Separate move history into a separate request.
//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();
}
}
}

View File

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

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

@@ -1,44 +0,0 @@
using Gameboard.ShogiUI.Sockets.Extensions;
using Gameboard.ShogiUI.Sockets.Repositories;
using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;
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 readonly IGameboardRepository gameboardRepository;
public ShogiUserClaimsTransformer(IGameboardRepository gameboardRepository)
{
this.gameboardRepository = gameboardRepository;
}
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var id = principal.UserId();
if (!string.IsNullOrWhiteSpace(id))
{
var user = await gameboardRepository.ReadUser(id);
if (user == null)
{
var newUser = principal.IsMicrosoft()
? Models.User.CreateMsalUser(id)
: Models.User.CreateGuestUser(id);
await gameboardRepository.CreateUser(newUser);
user = newUser;
}
if (user != null)
{
return new ClaimsPrincipal(user.CreateClaimsIdentity());
}
}
return principal;
}
}
}

View File

@@ -1,26 +0,0 @@
using Shogi.AcceptanceTests.TestSetup;
using Xunit.Abstractions;
namespace Shogi.AcceptanceTests
{
public class AcceptanceTests : IClassFixture<AATFixture>
{
private readonly AATFixture fixture;
private readonly ITestOutputHelper console;
public AcceptanceTests(AATFixture fixture, ITestOutputHelper console)
{
this.fixture = fixture;
this.console = console;
}
[Fact]
public async Task CreateAndReadSession()
{
var response = await fixture.Service.GetAsync(new Uri("Game", UriKind.Relative));
console.WriteLine(await response.Content.ReadAsStringAsync());
console.WriteLine(response.Headers.WwwAuthenticate.ToString());
response.IsSuccessStatusCode.Should().BeTrue(because: "AAT Client should be authorized.");
}
}
}

View File

@@ -0,0 +1,17 @@
using System;
namespace Shogi.Contracts.Api;
public class CreateGuestTokenResponse
{
public string UserId { get; }
public string DisplayName { get; }
public Guid OneTimeToken { get; }
public CreateGuestTokenResponse(string userId, string displayName, Guid oneTimeToken)
{
UserId = userId;
DisplayName = displayName;
OneTimeToken = oneTimeToken;
}
}

View File

@@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace Shogi.Contracts.Api;
public class CreateSessionCommand
{
[Required]
public string Name { get; set; } = string.Empty;
public bool IsPrivate { get; set; }
}

View File

@@ -0,0 +1,13 @@
using System;
namespace Shogi.Contracts.Api;
public class CreateTokenResponse
{
public Guid OneTimeToken { get; }
public CreateTokenResponse(Guid token)
{
OneTimeToken = token;
}
}

View File

@@ -0,0 +1,10 @@
using Shogi.Contracts.Types;
using System.ComponentModel.DataAnnotations;
namespace Shogi.Contracts.Api;
public class MovePieceCommand
{
[Required]
public Move Move { get; set; }
}

View File

@@ -0,0 +1,10 @@
using Shogi.Contracts.Types;
using System.Collections.Generic;
namespace Shogi.Contracts.Api;
public class ReadAllSessionsResponse
{
public IList<SessionMetadata> PlayerHasJoinedSessions { get; set; }
public IList<SessionMetadata> AllOtherSessions { get; set; }
}

View File

@@ -0,0 +1,8 @@
using Shogi.Contracts.Types;
namespace Shogi.Contracts.Api;
public class ReadSessionResponse
{
public Session Session { get; set; }
}

View File

@@ -5,7 +5,7 @@
<EnableNETAnalyzers>true</EnableNETAnalyzers> <EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>5</AnalysisLevel> <AnalysisLevel>5</AnalysisLevel>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild> <GeneratePackageOnBuild>False</GeneratePackageOnBuild>
<Title>Shogi Service Models</Title> <Title>Shogi Service Models</Title>
<Description>Contains DTOs use for http requests to Shogi backend services.</Description> <Description>Contains DTOs use for http requests to Shogi backend services.</Description>
</PropertyGroup> </PropertyGroup>

View File

@@ -0,0 +1,13 @@
using Shogi.Contracts.Types;
namespace Shogi.Contracts.Socket;
public class SessionCreatedSocketMessage : ISocketResponse
{
public SocketAction Action { get; }
public SessionCreatedSocketMessage()
{
Action = SocketAction.SessionCreated;
}
}

View File

@@ -0,0 +1,9 @@
using Shogi.Contracts.Types;
namespace Shogi.Contracts.Socket
{
public interface ISocketRequest
{
SocketAction Action { get; }
}
}

View File

@@ -0,0 +1,13 @@
using Shogi.Contracts.Types;
namespace Shogi.Contracts.Socket;
public interface ISocketResponse
{
SocketAction Action { get; }
}
public class SocketResponse : ISocketResponse
{
public SocketAction Action { get; set; }
}

View File

@@ -0,0 +1,18 @@
using Shogi.Contracts.Types;
namespace Shogi.Contracts.Socket;
public class MoveResponse : ISocketResponse
{
public SocketAction Action { get; }
public string SessionName { get; set; } = string.Empty;
/// <summary>
/// The player that made the move.
/// </summary>
public string PlayerName { get; set; } = string.Empty;
public MoveResponse()
{
Action = SocketAction.PieceMoved;
}
}

View File

@@ -1,7 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types namespace Shogi.Contracts.Types
{ {
public class BoardState public class BoardState
{ {

View File

@@ -1,4 +1,4 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types namespace Shogi.Contracts.Types
{ {
public class Move public class Move
{ {

View File

@@ -1,4 +1,4 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types namespace Shogi.Contracts.Types
{ {
public class Piece public class Piece
{ {

View File

@@ -1,10 +1,10 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types namespace Shogi.Contracts.Types
{ {
public class Session public class Session
{ {
public string Player1 { get; set; } public string Player1 { get; set; }
public string? Player2 { get; set; } public string? Player2 { get; set; }
public string GameName { get; set; } public string SessionName { get; set; }
public BoardState BoardState { get; set; } public BoardState BoardState { get; set; }
} }
} }

View File

@@ -0,0 +1,8 @@
namespace Shogi.Contracts.Types
{
public class SessionMetadata
{
public string Name { get; set; }
public int PlayerCount { get; set; }
}
}

View File

@@ -0,0 +1,9 @@
namespace Shogi.Contracts.Types
{
public enum SocketAction
{
SessionCreated,
SessionJoined,
PieceMoved
}
}

View File

@@ -1,4 +1,4 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types namespace Shogi.Contracts.Types
{ {
public class User public class User
{ {

View File

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

View File

@@ -1,4 +1,4 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Types namespace Shogi.Contracts.Types
{ {
public enum WhichPiece public enum WhichPiece
{ {

View File

@@ -0,0 +1,13 @@
/*
Post-Deployment Script Template
--------------------------------------------------------------------------------------
This file contains SQL statements that will be appended to the build script.
Use SQLCMD syntax to include a file in the post-deployment script.
Example: :r .\myfile.sql
Use SQLCMD syntax to reference a variable in the post-deployment script.
Example: :setvar TableName MyTable
SELECT * FROM [$(TableName)]
--------------------------------------------------------------------------------------
*/
:r .\Scripts\PopulateLoginPlatforms.sql

View File

@@ -0,0 +1,16 @@
DECLARE @LoginPlatforms TABLE (
[Platform] NVARCHAR(20)
)
INSERT INTO @LoginPlatforms ([Platform])
VALUES
('Guest'),
('Microsoft');
MERGE [user].[LoginPlatform] as t
USING @LoginPlatforms as s
ON t.[Platform] = s.[Platform]
WHEN NOT MATCHED THEN
INSERT ([Platform])
VALUES (s.[Platform]);

View File

@@ -0,0 +1 @@
CREATE SCHEMA [session]

View File

@@ -0,0 +1,16 @@
CREATE PROCEDURE [dbo].[CreateBoardState]
@boardStateDocument NVARCHAR(max),
@sessionName NVARCHAR(100)
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [session].[BoardState] (Document, SessionId)
SELECT
@boardStateDocument,
Id
FROM [session].[Session]
WHERE [Name] = @sessionName;
END

View File

@@ -0,0 +1,24 @@
CREATE PROCEDURE [session].[CreateSession]
@SessionName [session].[SessionName],
@Player1Name [user].[UserName],
@InitialBoardStateDocument [session].[JsonDocument]
AS
BEGIN
SET NOCOUNT ON
SET XACT_ABORT ON
BEGIN TRANSACTION
INSERT INTO [session].[Session] ([Name], Player1Id)
SELECT
@SessionName,
Id
FROM [user].[User]
WHERE [Name] = @Player1Name;
INSERT INTO [session].[BoardState] (Document, SessionId)
VALUES
(@InitialBoardStateDocument, SCOPE_IDENTITY());
COMMIT
END

View File

@@ -0,0 +1,12 @@
CREATE PROCEDURE [session].[ReadAllSessionsMetadata]
AS
SET NOCOUNT ON;
SELECT
[Name],
CASE Player2Id
WHEN NULL THEN 1
ELSE 2
END AS PlayerCount
FROM [session].[Session];

View File

@@ -0,0 +1,14 @@
CREATE PROCEDURE [dbo].[UpdateSession]
@SessionName [session].[SessionName],
@BoardStateJson [session].[JsonDocument]
AS
BEGIN
SET NOCOUNT ON;
UPDATE bs
SET bs.Document = @BoardStateJson
FROM [session].[BoardState] bs
INNER JOIN [session].[Session] s on s.Id = bs.SessionId
WHERE s.Name = @SessionName;
END

View File

@@ -0,0 +1,10 @@
CREATE TABLE [session].[BoardState]
(
[Id] BIGINT NOT NULL PRIMARY KEY IDENTITY,
[Document] NVARCHAR(max) NOT NULL,
[SessionId] BIGINT NOT NULL,
CONSTRAINT [Document must be json] CHECK (isjson(Document)=1),
CONSTRAINT FK_BoardState_Session FOREIGN KEY (SessionId)
REFERENCES [session].[Session] (Id) ON DELETE CASCADE ON UPDATE CASCADE
)

View File

@@ -0,0 +1,16 @@
CREATE TABLE [session].[Session]
(
Id BIGINT NOT NULL PRIMARY KEY IDENTITY,
[Name] [session].[SessionName] NOT NULL UNIQUE,
Created DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
GameOver BIT NOT NULL DEFAULT 0,
Player1Id BIGINT NOT NULL,
Player2Id BIGINT NULL,
CONSTRAINT FK_Player1_User FOREIGN KEY (Player1Id) REFERENCES [user].[User] (Id)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT FK_Player2_User FOREIGN KEY (Player2Id) REFERENCES [user].[User] (Id)
ON DELETE NO ACTION
ON UPDATE NO ACTION
)

View File

@@ -0,0 +1,2 @@
CREATE TYPE [session].[JsonDocument]
FROM NVARCHAR(max) NOT NULL

View File

@@ -0,0 +1,2 @@
CREATE TYPE [session].[SessionName]
FROM nvarchar(50) NOT NULL

View File

@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="4.0">
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<Name>Shogi.Database</Name>
<SchemaVersion>2.0</SchemaVersion>
<ProjectVersion>4.1</ProjectVersion>
<ProjectGuid>{9b115b71-088f-41ef-858f-c7b155271a9f}</ProjectGuid>
<DSP>Microsoft.Data.Tools.Schema.Sql.Sql130DatabaseSchemaProvider</DSP>
<OutputType>Database</OutputType>
<RootPath>
</RootPath>
<RootNamespace>Shogi.Database</RootNamespace>
<AssemblyName>Shogi.Database</AssemblyName>
<ModelCollation>1033, CI</ModelCollation>
<DefaultFileStructure>BySchemaAndSchemaType</DefaultFileStructure>
<DeployToDatabase>True</DeployToDatabase>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<TargetLanguage>CS</TargetLanguage>
<AppDesignerFolder>Properties</AppDesignerFolder>
<SqlServerVerification>False</SqlServerVerification>
<IncludeCompositeObjects>True</IncludeCompositeObjects>
<TargetDatabaseSet>True</TargetDatabaseSet>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<OutputPath>bin\Release\</OutputPath>
<BuildScriptName>$(MSBuildProjectName).sql</BuildScriptName>
<TreatWarningsAsErrors>False</TreatWarningsAsErrors>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<DefineDebug>false</DefineDebug>
<DefineTrace>true</DefineTrace>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<OutputPath>bin\Debug\</OutputPath>
<BuildScriptName>$(MSBuildProjectName).sql</BuildScriptName>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<DefineDebug>true</DefineDebug>
<DefineTrace>true</DefineTrace>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup>
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">11.0</VisualStudioVersion>
<!-- Default to the v11.0 targets path if the targets file for the current VS version is not found -->
<SSDTExists Condition="Exists('$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\SSDT\Microsoft.Data.Tools.Schema.SqlTasks.targets')">True</SSDTExists>
<VisualStudioVersion Condition="'$(SSDTExists)' == ''">11.0</VisualStudioVersion>
</PropertyGroup>
<Import Condition="'$(SQLDBExtensionsRefPath)' != ''" Project="$(SQLDBExtensionsRefPath)\Microsoft.Data.Tools.Schema.SqlTasks.targets" />
<Import Condition="'$(SQLDBExtensionsRefPath)' == ''" Project="$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\SSDT\Microsoft.Data.Tools.Schema.SqlTasks.targets" />
<ItemGroup>
<Folder Include="Properties" />
<Folder Include="Session" />
<Folder Include="User" />
<Folder Include="Session\Tables" />
<Folder Include="Session\Stored Procedures" />
<Folder Include="User\Tables" />
<Folder Include="Session\Types" />
<Folder Include="User\Types" />
<Folder Include="User\StoredProcedures" />
<Folder Include="Post Deployment" />
<Folder Include="Post Deployment\Scripts" />
</ItemGroup>
<ItemGroup>
<Build Include="Session\session.sql" />
<Build Include="User\user.sql" />
<Build Include="Session\Tables\Session.sql" />
<Build Include="Session\Tables\BoardState.sql" />
<Build Include="Session\Stored Procedures\CreateSession.sql" />
<Build Include="Session\Stored Procedures\CreateBoardState.sql" />
<Build Include="User\Tables\User.sql" />
<Build Include="Session\Types\SessionName.sql" />
<Build Include="User\Types\UserName.sql" />
<Build Include="Session\Types\JsonDocument.sql" />
<Build Include="User\StoredProcedures\CreateUser.sql" />
<Build Include="Session\Stored Procedures\ReadAllSessionsMetadata.sql" />
<Build Include="User\StoredProcedures\ReadUser.sql" />
<Build Include="User\Tables\LoginPlatform.sql" />
<None Include="Post Deployment\Scripts\PopulateLoginPlatforms.sql" />
<Build Include="Session\Stored Procedures\UpdateSession.sql" />
</ItemGroup>
<ItemGroup>
<PostDeploy Include="Post Deployment\Script.PostDeployment.sql" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,14 @@
CREATE PROCEDURE [user].[CreateUser]
@Name [user].[UserName],
@DisplayName NVARCHAR(100),
@Platform NVARCHAR(20)
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [user].[User] ([Name], DisplayName, [Platform])
VALUES
(@Name, @DisplayName, @Platform);
END

View File

@@ -0,0 +1,11 @@
CREATE PROCEDURE [user].[ReadUser]
@Name [user].[UserName]
AS
BEGIN
SELECT
[Name] as Id,
DisplayName,
[Platform]
FROM [user].[User]
WHERE [Name] = @Name;
END

View File

@@ -0,0 +1,4 @@
CREATE TABLE [user].[LoginPlatform]
(
[Platform] NVARCHAR(20) NOT NULL PRIMARY KEY
)

View File

@@ -0,0 +1,11 @@
CREATE TABLE [user].[User]
(
[Id] BIGINT NOT NULL PRIMARY KEY IDENTITY,
[Name] [user].[UserName] NOT NULL UNIQUE,
[DisplayName] NVARCHAR(100) NOT NULL,
[Platform] NVARCHAR(20) NOT NULL,
CONSTRAINT User_Platform FOREIGN KEY ([Platform]) References [user].[LoginPlatform] ([Platform])
ON DELETE CASCADE
ON UPDATE CASCADE
)

View File

@@ -0,0 +1,2 @@
CREATE TYPE [user].[UserName]
FROM nvarchar(100) NOT NULL

View File

@@ -0,0 +1 @@
CREATE SCHEMA [user]

View File

@@ -1,227 +0,0 @@
using FluentAssertions;
using Shogi.Domain.Pathing;
using Shogi.Domain.Pieces;
using System.Numerics;
using Xunit;
namespace Shogi.Domain.UnitTests
{
public class RookShould
{
public class MoveSet
{
private readonly Rook rook1;
private readonly Rook rook2;
public MoveSet()
{
this.rook1 = new Rook(WhichPlayer.Player1);
this.rook2 = new Rook(WhichPlayer.Player2);
}
[Fact]
public void Player1_HasCorrectMoveSet()
{
var moveSet = rook1.MoveSet;
moveSet.Should().HaveCount(4);
moveSet.Should().ContainEquivalentOf(new Path(Direction.Up, Distance.MultiStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.Left, Distance.MultiStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.Right, Distance.MultiStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.Down, Distance.MultiStep));
}
[Fact]
public void Player1_Promoted_HasCorrectMoveSet()
{
// Arrange
rook1.Promote();
rook1.IsPromoted.Should().BeTrue();
// Assert
var moveSet = rook1.MoveSet;
moveSet.Should().HaveCount(8);
moveSet.Should().ContainEquivalentOf(new Path(Direction.Up, Distance.MultiStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.Left, Distance.MultiStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.Right, Distance.MultiStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.Down, Distance.MultiStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.UpLeft, Distance.OneStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.DownLeft, Distance.OneStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.UpRight, Distance.OneStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.DownRight, Distance.OneStep));
}
[Fact]
public void Player2_HasCorrectMoveSet()
{
var moveSet = rook2.MoveSet;
moveSet.Should().HaveCount(4);
moveSet.Should().ContainEquivalentOf(new Path(Direction.Up, Distance.MultiStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.Left, Distance.MultiStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.Right, Distance.MultiStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.Down, Distance.MultiStep));
}
[Fact]
public void Player2_Promoted_HasCorrectMoveSet()
{
// Arrange
rook2.Promote();
rook2.IsPromoted.Should().BeTrue();
// Assert
var moveSet = rook2.MoveSet;
moveSet.Should().HaveCount(8);
moveSet.Should().ContainEquivalentOf(new Path(Direction.Up, Distance.MultiStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.Left, Distance.MultiStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.Right, Distance.MultiStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.Down, Distance.MultiStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.UpLeft, Distance.OneStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.DownLeft, Distance.OneStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.UpRight, Distance.OneStep));
moveSet.Should().ContainEquivalentOf(new Path(Direction.DownRight, Distance.OneStep));
}
}
private readonly Rook rookPlayer1;
private readonly Rook rookPlayer2;
public RookShould()
{
this.rookPlayer1 = new Rook(WhichPlayer.Player1);
this.rookPlayer2 = new Rook(WhichPlayer.Player2);
}
[Fact]
public void Promote()
{
this.rookPlayer1.IsPromoted.Should().BeFalse();
this.rookPlayer1.CanPromote.Should().BeTrue();
this.rookPlayer1.Promote();
this.rookPlayer1.IsPromoted.Should().BeTrue();
this.rookPlayer1.CanPromote.Should().BeFalse();
}
[Fact]
public void GetStepsFromStartToEnd_Player1NotPromoted_LateralMove()
{
Vector2 start = new(0, 0);
Vector2 end = new(0, 5);
var steps = rookPlayer1.GetPathFromStartToEnd(start, end);
rookPlayer1.IsPromoted.Should().BeFalse();
steps.Should().HaveCount(5);
steps.Should().Contain(new Vector2(0, 1));
steps.Should().Contain(new Vector2(0, 2));
steps.Should().Contain(new Vector2(0, 3));
steps.Should().Contain(new Vector2(0, 4));
steps.Should().Contain(new Vector2(0, 5));
}
[Fact]
public void GetStepsFromStartToEnd_Player1NotPromoted_DiagonalMove()
{
Vector2 start = new(0, 0);
Vector2 end = new(1, 1);
var steps = rookPlayer1.GetPathFromStartToEnd(start, end);
rookPlayer1.IsPromoted.Should().BeFalse();
steps.Should().BeEmpty();
}
[Fact]
public void GetStepsFromStartToEnd_Player1Promoted_LateralMove()
{
Vector2 start = new(0, 0);
Vector2 end = new(0, 5);
rookPlayer1.Promote();
var steps = rookPlayer1.GetPathFromStartToEnd(start, end);
rookPlayer1.IsPromoted.Should().BeTrue();
steps.Should().HaveCount(5);
steps.Should().Contain(new Vector2(0, 1));
steps.Should().Contain(new Vector2(0, 2));
steps.Should().Contain(new Vector2(0, 3));
steps.Should().Contain(new Vector2(0, 4));
steps.Should().Contain(new Vector2(0, 5));
}
[Fact]
public void GetStepsFromStartToEnd_Player1Promoted_DiagonalMove()
{
Vector2 start = new(0, 0);
Vector2 end = new(1, 1);
rookPlayer1.Promote();
var steps = rookPlayer1.GetPathFromStartToEnd(start, end);
rookPlayer1.IsPromoted.Should().BeTrue();
steps.Should().HaveCount(1);
steps.Should().Contain(new Vector2(1, 1));
}
[Fact]
public void GetStepsFromStartToEnd_Player2NotPromoted_LateralMove()
{
Vector2 start = new(0, 0);
Vector2 end = new(0, 5);
var steps = rookPlayer1.GetPathFromStartToEnd(start, end);
rookPlayer1.IsPromoted.Should().BeFalse();
steps.Should().HaveCount(5);
steps.Should().Contain(new Vector2(0, 1));
steps.Should().Contain(new Vector2(0, 2));
steps.Should().Contain(new Vector2(0, 3));
steps.Should().Contain(new Vector2(0, 4));
steps.Should().Contain(new Vector2(0, 5));
}
[Fact]
public void GetStepsFromStartToEnd_Player2NotPromoted_DiagonalMove()
{
Vector2 start = new(0, 0);
Vector2 end = new(1, 1);
var steps = rookPlayer1.GetPathFromStartToEnd(start, end);
rookPlayer1.IsPromoted.Should().BeFalse();
steps.Should().BeEmpty();
}
[Fact]
public void GetStepsFromStartToEnd_Player2Promoted_LateralMove()
{
Vector2 start = new(0, 0);
Vector2 end = new(0, 5);
rookPlayer1.Promote();
var steps = rookPlayer1.GetPathFromStartToEnd(start, end);
rookPlayer1.IsPromoted.Should().BeTrue();
steps.Should().HaveCount(5);
steps.Should().Contain(new Vector2(0, 1));
steps.Should().Contain(new Vector2(0, 2));
steps.Should().Contain(new Vector2(0, 3));
steps.Should().Contain(new Vector2(0, 4));
steps.Should().Contain(new Vector2(0, 5));
}
[Fact]
public void GetStepsFromStartToEnd_Player2Promoted_DiagonalMove()
{
Vector2 start = new(0, 0);
Vector2 end = new(1, 1);
rookPlayer1.Promote();
var steps = rookPlayer1.GetPathFromStartToEnd(start, end);
rookPlayer1.IsPromoted.Should().BeTrue();
steps.Should().HaveCount(1);
steps.Should().Contain(new Vector2(1, 1));
}
}
}

View File

@@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
<PackageReference Include="xunit" Version="2.4.2-pre.12" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Shogi.Domain\Shogi.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,463 +0,0 @@
using FluentAssertions;
using FluentAssertions.Execution;
using System;
using Xunit;
using Xunit.Abstractions;
namespace Shogi.Domain.UnitTests
{
public class ShogiShould
{
private readonly ITestOutputHelper console;
public ShogiShould(ITestOutputHelper console)
{
this.console = console;
}
[Fact]
public void MoveAPieceToAnEmptyPosition()
{
// Arrange
var board = new BoardState();
var shogi = new Session(board);
board["A4"].Should().BeNull();
var expectedPiece = board["A3"];
expectedPiece.Should().NotBeNull();
// Act
shogi.Move("A3", "A4", false);
// Assert
board["A3"].Should().BeNull();
board["A4"].Should().Be(expectedPiece);
}
[Fact]
public void AllowValidMoves_AfterCheck()
{
// Arrange
var board = new BoardState();
var shogi = new Session(board);
// P1 Pawn
shogi.Move("C3", "C4", false);
// P2 Pawn
shogi.Move("G7", "G6", false);
// P1 Bishop puts P2 in check
shogi.Move("B2", "G7", false);
board.InCheck.Should().Be(WhichPlayer.Player2);
// Act - P2 is able to un-check theirself.
/// P2 King moves out of check
shogi.Move("E9", "E8", false);
// Assert
using (new AssertionScope())
{
board.InCheck.Should().BeNull();
}
}
[Fact]
public void PreventInvalidMoves_MoveFromEmptyPosition()
{
// Arrange
var board = new BoardState();
var shogi = new Session(board);
board["D5"].Should().BeNull();
// Act
var act = () => shogi.Move("D5", "D6", false);
// Assert
act.Should().Throw<InvalidOperationException>();
board["D5"].Should().BeNull();
board["D6"].Should().BeNull();
board.Player1Hand.Should().BeEmpty();
board.Player2Hand.Should().BeEmpty();
}
[Fact]
public void PreventInvalidMoves_MoveToCurrentPosition()
{
// Arrange
var board = new BoardState();
var shogi = new Session(board);
var expectedPiece = board["A3"];
// Act - P1 "moves" pawn to the position it already exists at.
var act = () => shogi.Move("A3", "A3", false);
// Assert
using (new AssertionScope())
{
act.Should().Throw<InvalidOperationException>();
board["A3"].Should().Be(expectedPiece);
board.Player1Hand.Should().BeEmpty();
board.Player2Hand.Should().BeEmpty();
}
}
[Fact]
public void PreventInvalidMoves_MoveSet()
{
// Arrange
var board = new BoardState();
var shogi = new Session(board);
var expectedPiece = board["A1"];
expectedPiece!.WhichPiece.Should().Be(WhichPiece.Lance);
// Act - Move Lance illegally
var act = () => shogi.Move("A1", "D5", false);
// Assert
using (new AssertionScope())
{
act.Should().Throw<InvalidOperationException>();
board["A1"].Should().Be(expectedPiece);
board["A5"].Should().BeNull();
board.Player1Hand.Should().BeEmpty();
board.Player2Hand.Should().BeEmpty();
}
}
[Fact]
public void PreventInvalidMoves_Ownership()
{
// Arrange
var board = new BoardState();
var shogi = new Session(board);
var expectedPiece = board["A7"];
expectedPiece!.Owner.Should().Be(WhichPlayer.Player2);
board.WhoseTurn.Should().Be(WhichPlayer.Player1);
// Act - Move Player2 Pawn when it is Player1 turn.
var act = () => shogi.Move("A7", "A6", false);
// Assert
using (new AssertionScope())
{
act.Should().Throw<InvalidOperationException>();
board["A7"].Should().Be(expectedPiece);
board["A6"].Should().BeNull();
}
}
[Fact]
public void PreventInvalidMoves_MoveThroughAllies()
{
// Arrange
var board = new BoardState();
var shogi = new Session(board);
var lance = board["A1"];
var pawn = board["A3"];
lance!.Owner.Should().Be(pawn!.Owner);
// Act - Move P1 Lance through P1 Pawn.
var act = () => shogi.Move("A1", "A5", false);
// Assert
using (new AssertionScope())
{
act.Should().Throw<InvalidOperationException>();
board["A1"].Should().Be(lance);
board["A3"].Should().Be(pawn);
board["A5"].Should().BeNull();
}
}
[Fact]
public void PreventInvalidMoves_CaptureAlly()
{
// Arrange
var board = new BoardState();
var shogi = new Session(board);
var knight = board["B1"];
var pawn = board["C3"];
knight!.Owner.Should().Be(pawn!.Owner);
// Act - P1 Knight tries to capture P1 Pawn.
var act = () => shogi.Move("B1", "C3", false);
// Arrange
using (new AssertionScope())
{
act.Should().Throw<InvalidOperationException>();
board["B1"].Should().Be(knight);
board["C3"].Should().Be(pawn);
board.Player1Hand.Should().BeEmpty();
board.Player2Hand.Should().BeEmpty();
}
}
[Fact]
public void PreventInvalidMoves_Check()
{
// Arrange
var board = new BoardState();
var shogi = new Session(board);
// P1 Pawn
shogi.Move("C3", "C4", false);
// P2 Pawn
shogi.Move("G7", "G6", false);
// P1 Bishop puts P2 in check
shogi.Move("B2", "G7", false);
board.InCheck.Should().Be(WhichPlayer.Player2);
var lance = board["I9"];
// Act - P2 moves Lance while in check.
var act = () => shogi.Move("I9", "I8", false);
// Assert
using (new AssertionScope())
{
act.Should().Throw<InvalidOperationException>();
board.InCheck.Should().Be(WhichPlayer.Player2);
board["I9"].Should().Be(lance);
board["I8"].Should().BeNull();
}
}
[Fact]
// TODO: Consider nesting classes to share this setup in a constructor but have act and assert as separate facts.
public void PreventInvalidDrops_MoveSet()
{
// Arrange
var board = new BoardState();
var shogi = new Session(board);
// P1 Pawn
shogi.Move("C3", "C4", false);
// P2 Pawn
shogi.Move("I7", "I6", false);
// P1 Bishop takes P2 Pawn.
shogi.Move("B2", "G7", false);
// P2 Gold, block check from P1 Bishop.
shogi.Move("F9", "F8", false);
// P1 Bishop takes P2 Bishop, promotes so it can capture P2 Knight and P2 Lance
shogi.Move("G7", "H8", true);
// P2 Pawn again
shogi.Move("I6", "I5", false);
// P1 Bishop takes P2 Knight
shogi.Move("H8", "H9", false);
// P2 Pawn again
shogi.Move("I5", "I4", false);
// P1 Bishop takes P2 Lance
shogi.Move("H9", "I9", false);
// P2 Pawn captures P1 Pawn
shogi.Move("I4", "I3", false);
board.Player1Hand.Count.Should().Be(4);
board.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Knight);
board.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Lance);
board.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Pawn);
board.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop);
board.WhoseTurn.Should().Be(WhichPlayer.Player1);
// Act | Assert - Illegally placing Knight from the hand in farthest rank.
board["H9"].Should().BeNull();
var act = () => shogi.Move(WhichPiece.Knight, "H9");
act.Should().Throw<InvalidOperationException>();
board["H9"].Should().BeNull();
board.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Knight);
// Act | Assert - Illegally placing Knight from the hand in second farthest row.
board["H8"].Should().BeNull();
act = () => shogi.Move(WhichPiece.Knight, "H8");
act.Should().Throw<InvalidOperationException>();
board["H8"].Should().BeNull();
board.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Knight);
// Act | Assert - Illegally place Lance from the hand.
board["H9"].Should().BeNull();
act = () => shogi.Move(WhichPiece.Knight, "H9");
act.Should().Throw<InvalidOperationException>();
board["H9"].Should().BeNull();
board.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Lance);
// Act | Assert - Illegally place Pawn from the hand.
board["H9"].Should().BeNull();
act = () => shogi.Move(WhichPiece.Pawn, "H9");
act.Should().Throw<InvalidOperationException>();
board["H9"].Should().BeNull();
board.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Pawn);
// // Act | Assert - Illegally place Pawn from the hand in a row which already has an unpromoted Pawn.
// // TODO
}
//[Fact]
//public void PreventInvalidDrop_Check()
//{
// // Arrange
// var moves = new[]
// {
// // P1 Pawn
// new Move("C3", "C4"),
// // P2 Pawn
// new Move("G7", "G6"),
// // P1 Pawn, arbitrary move.
// new Move("A3", "A4"),
// // P2 Bishop takes P1 Bishop
// new Move("H8", "B2"),
// // P1 Silver takes P2 Bishop
// new Move("C1", "B2"),
// // P2 Pawn, arbtrary move
// new Move("A7", "A6"),
// // P1 drop Bishop, place P2 in check
// new Move(WhichPiece.Bishop, "G7")
// };
// var shogi = new Shogi(moves);
// shogi.InCheck.Should().Be(WhichPlayer.Player2);
// shogi.Player2Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop);
// boardState["E5"].Should().BeNull();
// // Act - P2 places a Bishop while in check.
// var dropSuccess = shogi.Move(new Move(WhichPiece.Bishop, "E5"));
// // Assert
// dropSuccess.Should().BeFalse();
// boardState["E5"].Should().BeNull();
// shogi.InCheck.Should().Be(WhichPlayer.Player2);
// shogi.Player2Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop);
//}
//[Fact]
//public void PreventInvalidDrop_Capture()
//{
// // Arrange
// var moves = new[]
// {
// // P1 Pawn
// new Move("C3", "C4"),
// // P2 Pawn
// new Move("G7", "G6"),
// // P1 Bishop capture P2 Bishop
// new Move("B2", "H8"),
// // P2 Pawn
// new Move("G6", "G5")
// };
// var shogi = new Shogi(moves);
// using (new AssertionScope())
// {
// shogi.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop);
// boardState["I9"].Should().NotBeNull();
// boardState["I9"].WhichPiece.Should().Be(WhichPiece.Lance);
// boardState["I9"].Owner.Should().Be(WhichPlayer.Player2);
// }
// // Act - P1 tries to place a piece where an opponent's piece resides.
// var dropSuccess = shogi.Move(new Move(WhichPiece.Bishop, "I9"));
// // Assert
// using (new AssertionScope())
// {
// dropSuccess.Should().BeFalse();
// shogi.Player1Hand.Should().ContainSingle(_ => _.WhichPiece == WhichPiece.Bishop);
// boardState["I9"].Should().NotBeNull();
// boardState["I9"].WhichPiece.Should().Be(WhichPiece.Lance);
// boardState["I9"].Owner.Should().Be(WhichPlayer.Player2);
// }
//}
[Fact]
public void Check()
{
// Arrange
var boardState = new BoardState();
var shogi = new Session(boardState);
// P1 Pawn
shogi.Move("C3", "C4", false);
// P2 Pawn
shogi.Move("G7", "G6", false);
// Act - P1 Bishop, check
shogi.Move("B2", "G7", false);
// Assert
boardState.InCheck.Should().Be(WhichPlayer.Player2);
}
[Fact]
public void Promote()
{
// Arrange
var boardState = new BoardState();
var shogi = new Session(boardState);
// P1 Pawn
shogi.Move("C3", "C4", false);
// P2 Pawn
shogi.Move("G7", "G6", false);
// Act - P1 moves across promote threshold.
shogi.Move("B2", "G7", true);
// Assert
using (new AssertionScope())
{
boardState["B2"].Should().BeNull();
boardState["G7"].Should().NotBeNull();
boardState["G7"]!.WhichPiece.Should().Be(WhichPiece.Bishop);
boardState["G7"]!.Owner.Should().Be(WhichPlayer.Player1);
boardState["G7"]!.IsPromoted.Should().BeTrue();
}
}
[Fact]
public void Capture()
{
// Arrange
var boardState = new BoardState();
var shogi = new Session(boardState);
var p1Bishop = boardState["B2"];
p1Bishop!.WhichPiece.Should().Be(WhichPiece.Bishop);
shogi.Move("C3", "C4", false);
shogi.Move("G7", "G6", false);
// Act - P1 Bishop captures P2 Bishop
shogi.Move("B2", "H8", false);
// Assert
boardState["B2"].Should().BeNull();
boardState["H8"].Should().Be(p1Bishop);
boardState
.Player1Hand
.Should()
.ContainSingle(p => p.WhichPiece == WhichPiece.Bishop && p.Owner == WhichPlayer.Player1);
}
[Fact]
public void CheckMate()
{
// Arrange
var boardState = new BoardState();
var shogi = new Session(boardState);
// P1 Rook
shogi.Move("H2", "E2", false);
// P2 Gold
shogi.Move("F9", "G8", false);
// P1 Pawn
shogi.Move("E3", "E4", false);
// P2 other Gold
shogi.Move("D9", "C8", false);
// P1 same Pawn
shogi.Move("E4", "E5", false);
// P2 Pawn
shogi.Move("E7", "E6", false);
// P1 Pawn takes P2 Pawn
shogi.Move("E5", "E6", false);
// P2 King
shogi.Move("E9", "E8", false);
// P1 Pawn promotes; threatens P2 King
shogi.Move("E6", "E7", true);
// P2 King retreat
shogi.Move("E8", "E9", false);
// Act - P1 Pawn wins by checkmate.
shogi.Move("E7", "E8", false);
// Assert - checkmate
console.WriteLine(shogi.ToStringStateAsAscii());
boardState.IsCheckmate.Should().BeTrue();
boardState.InCheck.Should().Be(WhichPlayer.Player2);
}
}
}

View File

@@ -0,0 +1,242 @@
using Shogi.Domain.ValueObjects;
using System.Text;
namespace Shogi.Domain;
/// <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 sealed class Session
{
private readonly StandardRules rules;
public Session(string name, BoardState initialState, string player1, string? player2 = null)
{
Name = name;
Player1 = player1;
Player2 = player2;
BoardState = initialState;
rules = new StandardRules(BoardState);
}
public BoardState BoardState { get; }
public string Name { get; }
public string Player1 { get; }
public string? Player2 { get; }
/// <summary>
/// Move a piece from a board position to another board position, potentially capturing an opponents piece. Respects all rules of the game.
/// </summary>
/// <remarks>
/// The strategy involves simulating a move on a throw-away board state that can be used to
/// validate legal vs illegal moves without having to worry about reverting board state.
/// </remarks>
/// <exception cref="InvalidOperationException"></exception>
public void Move(string from, string to, bool isPromotion)
{
var simulationState = new BoardState(BoardState);
var simulation = new StandardRules(simulationState);
var moveResult = simulation.Move(from, to, isPromotion);
if (!moveResult.Success)
{
throw new InvalidOperationException(moveResult.Reason);
}
// If already in check, assert the move that resulted in check no longer results in check.
if (BoardState.InCheck == BoardState.WhoseTurn
&& simulation.IsOpposingKingThreatenedByPosition(BoardState.PreviousMoveTo))
{
throw new InvalidOperationException("Unable to move because you are still in check.");
}
var otherPlayer = BoardState.WhoseTurn == WhichPlayer.Player1 ? WhichPlayer.Player2 : WhichPlayer.Player1;
if (simulation.IsPlayerInCheckAfterMove())
{
throw new InvalidOperationException("Illegal move. This move places you in check.");
}
_ = rules.Move(from, to, isPromotion);
if (rules.IsOpponentInCheckAfterMove())
{
BoardState.InCheck = otherPlayer;
if (rules.IsOpponentInCheckMate())
{
BoardState.IsCheckmate = true;
}
}
else
{
BoardState.InCheck = null;
}
BoardState.WhoseTurn = otherPlayer;
}
public void Move(WhichPiece pieceInHand, string to)
{
var index = BoardState.ActivePlayerHand.FindIndex(p => p.WhichPiece == pieceInHand);
if (index == -1)
{
throw new InvalidOperationException($"{pieceInHand} does not exist in the hand.");
}
if (BoardState[to] != null)
{
throw new InvalidOperationException("Illegal placement of piece from the hand. Destination is not empty.");
}
var toVector = Notation.FromBoardNotation(to);
switch (pieceInHand)
{
case WhichPiece.Knight:
{
// Knight cannot be placed onto the farthest two ranks from the hand.
if (BoardState.WhoseTurn == WhichPlayer.Player1 && toVector.Y > 6
|| BoardState.WhoseTurn == WhichPlayer.Player2 && toVector.Y < 2)
{
throw new InvalidOperationException("Illegal move. Knight has no valid moves after placement.");
}
break;
}
case WhichPiece.Lance:
case WhichPiece.Pawn:
{
// Lance and Pawn cannot be placed onto the farthest rank from the hand.
if (BoardState.WhoseTurn == WhichPlayer.Player1 && toVector.Y == 8
|| BoardState.WhoseTurn == WhichPlayer.Player2 && toVector.Y == 0)
{
throw new InvalidOperationException($"Illegal move. {pieceInHand} has no valid moves after placement.");
}
break;
}
}
var tempBoard = new BoardState(BoardState);
var simulation = new StandardRules(tempBoard);
var moveResult = simulation.Move(pieceInHand, to);
if (!moveResult.Success)
{
throw new InvalidOperationException(moveResult.Reason);
}
var otherPlayer = tempBoard.WhoseTurn == WhichPlayer.Player1 ? WhichPlayer.Player2 : WhichPlayer.Player1;
if (BoardState.InCheck == BoardState.WhoseTurn)
{
//if (simulation.IsPlayerInCheckAfterMove(boardState.PreviousMoveTo, toVector, boardState.WhoseTurn))
//{
// throw new InvalidOperationException("Illegal move. You're still in check!");
//}
}
var kingPosition = otherPlayer == WhichPlayer.Player1 ? tempBoard.Player1KingPosition : tempBoard.Player2KingPosition;
//if (simulation.IsPlayerInCheckAfterMove(toVector, kingPosition, otherPlayer))
//{
//}
//rules.Move(from, to, isPromotion);
//if (rules.IsPlayerInCheckAfterMove(fromVector, toVector, otherPlayer))
//{
// board.InCheck = otherPlayer;
// board.IsCheckmate = rules.EvaluateCheckmate();
//}
//else
//{
// board.InCheck = null;
//}
BoardState.WhoseTurn = otherPlayer;
}
/// <summary>
/// Prints a ASCII representation of the board for debugging board state.
/// </summary>
/// <returns></returns>
public string ToStringStateAsAscii()
{
var builder = new StringBuilder();
builder.Append(" ");
builder.Append("Player 2(.)");
builder.AppendLine();
for (var rank = 8; rank >= 0; rank--)
{
// Horizontal line
builder.Append(" - ");
for (var file = 0; file < 8; file++) builder.Append("- - ");
builder.Append("- -");
// Print Rank ruler.
builder.AppendLine();
builder.Append($"{rank + 1} ");
// Print pieces.
builder.Append(" |");
for (var x = 0; x < 9; x++)
{
var piece = BoardState[x, rank];
if (piece == null)
{
builder.Append(" ");
}
else
{
builder.AppendFormat("{0}", ToAscii(piece));
}
builder.Append('|');
}
builder.AppendLine();
}
// Horizontal line
builder.Append(" - ");
for (var x = 0; x < 8; x++) builder.Append("- - ");
builder.Append("- -");
builder.AppendLine();
builder.Append(" ");
builder.Append("Player 1");
builder.AppendLine();
builder.AppendLine();
// Print File ruler.
builder.Append(" ");
builder.Append(" A B C D E F G H I ");
return builder.ToString();
}
/// <summary>
///
/// </summary>
/// <param name="piece"></param>
/// <returns>
/// A string with three characters.
/// The first character indicates promotion status.
/// The second character indicates piece.
/// The third character indicates ownership.
/// </returns>
private static string ToAscii(Piece piece)
{
var builder = new StringBuilder();
if (piece.IsPromoted) builder.Append('^');
else builder.Append(' ');
var name = piece.WhichPiece switch
{
WhichPiece.King => "K",
WhichPiece.GoldGeneral => "G",
WhichPiece.SilverGeneral => "S",
WhichPiece.Bishop => "B",
WhichPiece.Rook => "R",
WhichPiece.Knight => "k",
WhichPiece.Lance => "L",
WhichPiece.Pawn => "P",
_ => throw new ArgumentException($"Unknown value for {nameof(WhichPiece)}."),
};
builder.Append(name);
if (piece.Owner == WhichPlayer.Player2) builder.Append('.');
else builder.Append(' ');
return builder.ToString();
}
}

View File

@@ -1,246 +1,251 @@
using Shogi.Domain.Pieces; using Shogi.Domain.ValueObjects;
using BoardTile = System.Collections.Generic.KeyValuePair<System.Numerics.Vector2, Shogi.Domain.Pieces.Piece>; using BoardTile = System.Collections.Generic.KeyValuePair<System.Numerics.Vector2, Shogi.Domain.ValueObjects.Piece>;
namespace Shogi.Domain namespace Shogi.Domain
{ {
public class BoardState public class BoardState
{ {
public delegate void ForEachDelegate(Piece element, Vector2 position); /// <summary>
/// <summary> /// Board state before any moves have been made, using standard setup and rules.
/// Key is position notation, such as "E4". /// </summary>
/// </summary> public static readonly BoardState StandardStarting = new();
private readonly Dictionary<string, Piece?> board;
public delegate void ForEachDelegate(Piece element, Vector2 position);
/// <summary>
/// Key is position notation, such as "E4".
/// </summary>
private readonly Dictionary<string, Piece?> board;
public BoardState(Dictionary<string, Piece?> state) public BoardState(Dictionary<string, Piece?> state)
{ {
board = state; board = state;
Player1Hand = new List<Piece>(); Player1Hand = new List<Piece>();
Player2Hand = new List<Piece>(); Player2Hand = new List<Piece>();
PreviousMoveTo = Vector2.Zero; PreviousMoveTo = Vector2.Zero;
} }
public BoardState() public BoardState()
{ {
board = new Dictionary<string, Piece?>(81, StringComparer.OrdinalIgnoreCase); board = new Dictionary<string, Piece?>(81, StringComparer.OrdinalIgnoreCase);
InitializeBoardState(); InitializeBoardState();
Player1Hand = new List<Piece>(); Player1Hand = new List<Piece>();
Player2Hand = new List<Piece>(); Player2Hand = new List<Piece>();
PreviousMoveTo = Vector2.Zero; PreviousMoveTo = Vector2.Zero;
} }
public Dictionary<string, Piece?> State => board; public Dictionary<string, Piece?> State => board;
public List<Piece> ActivePlayerHand => WhoseTurn == WhichPlayer.Player1 ? Player1Hand : Player2Hand; public List<Piece> ActivePlayerHand => WhoseTurn == WhichPlayer.Player1 ? Player1Hand : Player2Hand;
public Vector2 Player1KingPosition => Notation.FromBoardNotation(this.board.Where(kvp => kvp.Value != null).Single(kvp => public Vector2 Player1KingPosition => Notation.FromBoardNotation(this.board.Where(kvp => kvp.Value != null).Single(kvp =>
{ {
var piece = kvp.Value; var piece = kvp.Value;
return piece!.IsKing() && piece!.Owner == WhichPlayer.Player1; return piece!.IsKing() && piece!.Owner == WhichPlayer.Player1;
}).Key); }).Key);
public Vector2 Player2KingPosition => Notation.FromBoardNotation(this.board.Where(kvp => kvp.Value != null).Single(kvp => public Vector2 Player2KingPosition => Notation.FromBoardNotation(this.board.Where(kvp => kvp.Value != null).Single(kvp =>
{ {
var piece = kvp.Value; var piece = kvp.Value;
return piece!.IsKing() && piece!.Owner == WhichPlayer.Player2; return piece!.IsKing() && piece!.Owner == WhichPlayer.Player2;
}).Key); }).Key);
public List<Piece> Player1Hand { get; } public List<Piece> Player1Hand { get; }
public List<Piece> Player2Hand { get; } public List<Piece> Player2Hand { get; }
public Vector2 PreviousMoveFrom { get; private set; } public Vector2 PreviousMoveFrom { get; private set; }
public Vector2 PreviousMoveTo { get; private set; } public Vector2 PreviousMoveTo { get; private set; }
public WhichPlayer WhoseTurn { get; set; } public WhichPlayer WhoseTurn { get; set; }
public WhichPlayer? InCheck { get; set; } public WhichPlayer? InCheck { get; set; }
public bool IsCheckmate { get; set; } public bool IsCheckmate { get; set; }
/// <summary> /// <summary>
/// Copy constructor. /// Copy constructor.
/// </summary> /// </summary>
public BoardState(BoardState other) : this() public BoardState(BoardState other) : this()
{ {
foreach (var kvp in other.board) foreach (var kvp in other.board)
{ {
// Replace copy constructor with static factory method in Piece.cs // Replace copy constructor with static factory method in Piece.cs
board[kvp.Key] = kvp.Value == null ? null : Piece.CreateCopy(kvp.Value); board[kvp.Key] = kvp.Value == null ? null : Piece.CreateCopy(kvp.Value);
} }
WhoseTurn = other.WhoseTurn; WhoseTurn = other.WhoseTurn;
InCheck = other.InCheck; InCheck = other.InCheck;
IsCheckmate = other.IsCheckmate; IsCheckmate = other.IsCheckmate;
PreviousMoveTo = other.PreviousMoveTo; PreviousMoveTo = other.PreviousMoveTo;
Player1Hand.AddRange(other.Player1Hand); Player1Hand.AddRange(other.Player1Hand);
Player2Hand.AddRange(other.Player2Hand); Player2Hand.AddRange(other.Player2Hand);
} }
public Piece? this[string notation] public Piece? this[string notation]
{ {
// TODO: Validate "notation" here and throw an exception if invalid. // TODO: Validate "notation" here and throw an exception if invalid.
get => board[notation]; get => board[notation];
set => board[notation] = value; set => board[notation] = value;
} }
public Piece? this[Vector2 vector] public Piece? this[Vector2 vector]
{ {
get => this[Notation.ToBoardNotation(vector)]; get => this[Notation.ToBoardNotation(vector)];
set => this[Notation.ToBoardNotation(vector)] = value; set => this[Notation.ToBoardNotation(vector)] = value;
} }
public Piece? this[int x, int y] public Piece? this[int x, int y]
{ {
get => this[Notation.ToBoardNotation(x, y)]; get => this[Notation.ToBoardNotation(x, y)];
set => this[Notation.ToBoardNotation(x, y)] = value; set => this[Notation.ToBoardNotation(x, y)] = value;
} }
internal void RememberAsMostRecentMove(Vector2 from, Vector2 to) internal void RememberAsMostRecentMove(Vector2 from, Vector2 to)
{ {
PreviousMoveFrom = from; PreviousMoveFrom = from;
PreviousMoveTo = to; PreviousMoveTo = to;
} }
/// <summary> /// <summary>
/// Returns true if the given path can be traversed without colliding into a piece. /// Returns true if the given path can be traversed without colliding into a piece.
/// </summary> /// </summary>
public bool IsPathBlocked(IEnumerable<Vector2> path) public bool IsPathBlocked(IEnumerable<Vector2> path)
{ {
return !path.Any() return !path.Any()
|| path.SkipLast(1).Any(position => this[position] != null) || path.SkipLast(1).Any(position => this[position] != null)
|| this[path.Last()]?.Owner == WhoseTurn; || this[path.Last()]?.Owner == WhoseTurn;
} }
internal bool IsWithinPromotionZone(Vector2 position) internal bool IsWithinPromotionZone(Vector2 position)
{ {
return (WhoseTurn == WhichPlayer.Player1 && position.Y > 5) return (WhoseTurn == WhichPlayer.Player1 && position.Y > 5)
|| (WhoseTurn == WhichPlayer.Player2 && position.Y < 3); || (WhoseTurn == WhichPlayer.Player2 && position.Y < 3);
} }
internal static bool IsWithinBoardBoundary(Vector2 position) internal static bool IsWithinBoardBoundary(Vector2 position)
{ {
return position.X <= 8 && position.X >= 0 return position.X <= 8 && position.X >= 0
&& position.Y <= 8 && position.Y >= 0; && position.Y <= 8 && position.Y >= 0;
} }
internal List<BoardTile> GetTilesOccupiedBy(WhichPlayer whichPlayer) => board internal List<BoardTile> GetTilesOccupiedBy(WhichPlayer whichPlayer) => board
.Where(kvp => kvp.Value?.Owner == whichPlayer) .Where(kvp => kvp.Value?.Owner == whichPlayer)
.Select(kvp => new BoardTile(Notation.FromBoardNotation(kvp.Key), kvp.Value!)) .Select(kvp => new BoardTile(Notation.FromBoardNotation(kvp.Key), kvp.Value!))
.ToList(); .ToList();
internal void Capture(Vector2 to) internal void Capture(Vector2 to)
{ {
var piece = this[to]; var piece = this[to];
if (piece == null) throw new InvalidOperationException("Cannot capture. Piece at position does not exist."); if (piece == null) throw new InvalidOperationException("Cannot capture. Piece at position does not exist.");
piece.Capture(WhoseTurn); piece.Capture(WhoseTurn);
ActivePlayerHand.Add(piece); ActivePlayerHand.Add(piece);
} }
/// <summary> /// <summary>
/// Does not include the start position. /// Does not include the start position.
/// </summary> /// </summary>
internal static IEnumerable<Vector2> GetPathAlongDirectionFromStartToEdgeOfBoard(Vector2 start, Vector2 direction) internal static IEnumerable<Vector2> GetPathAlongDirectionFromStartToEdgeOfBoard(Vector2 start, Vector2 direction)
{ {
var next = start; var next = start;
while (IsWithinBoardBoundary(next + direction)) while (IsWithinBoardBoundary(next + direction))
{ {
next += direction; next += direction;
yield return next; yield return next;
} }
} }
internal Piece? QueryFirstPieceInPath(IEnumerable<Vector2> path) internal Piece? QueryFirstPieceInPath(IEnumerable<Vector2> path)
{ {
foreach (var step in path) foreach (var step in path)
{ {
if (this[step] != null) return this[step]; if (this[step] != null) return this[step];
} }
return null; return null;
} }
private void InitializeBoardState() private void InitializeBoardState()
{ {
this["A1"] = new Lance(WhichPlayer.Player1); this["A1"] = new Lance(WhichPlayer.Player1);
this["B1"] = new Knight(WhichPlayer.Player1); this["B1"] = new Knight(WhichPlayer.Player1);
this["C1"] = new SilverGeneral(WhichPlayer.Player1); this["C1"] = new SilverGeneral(WhichPlayer.Player1);
this["D1"] = new GoldGeneral(WhichPlayer.Player1); this["D1"] = new GoldGeneral(WhichPlayer.Player1);
this["E1"] = new King(WhichPlayer.Player1); this["E1"] = new King(WhichPlayer.Player1);
this["F1"] = new GoldGeneral(WhichPlayer.Player1); this["F1"] = new GoldGeneral(WhichPlayer.Player1);
this["G1"] = new SilverGeneral(WhichPlayer.Player1); this["G1"] = new SilverGeneral(WhichPlayer.Player1);
this["H1"] = new Knight(WhichPlayer.Player1); this["H1"] = new Knight(WhichPlayer.Player1);
this["I1"] = new Lance(WhichPlayer.Player1); this["I1"] = new Lance(WhichPlayer.Player1);
this["A2"] = null; this["A2"] = null;
this["B2"] = new Bishop(WhichPlayer.Player1); this["B2"] = new Bishop(WhichPlayer.Player1);
this["C2"] = null; this["C2"] = null;
this["D2"] = null; this["D2"] = null;
this["E2"] = null; this["E2"] = null;
this["F2"] = null; this["F2"] = null;
this["G2"] = null; this["G2"] = null;
this["H2"] = new Rook(WhichPlayer.Player1); this["H2"] = new Rook(WhichPlayer.Player1);
this["I2"] = null; this["I2"] = null;
this["A3"] = new Pawn(WhichPlayer.Player1); this["A3"] = new Pawn(WhichPlayer.Player1);
this["B3"] = new Pawn(WhichPlayer.Player1); this["B3"] = new Pawn(WhichPlayer.Player1);
this["C3"] = new Pawn(WhichPlayer.Player1); this["C3"] = new Pawn(WhichPlayer.Player1);
this["D3"] = new Pawn(WhichPlayer.Player1); this["D3"] = new Pawn(WhichPlayer.Player1);
this["E3"] = new Pawn(WhichPlayer.Player1); this["E3"] = new Pawn(WhichPlayer.Player1);
this["F3"] = new Pawn(WhichPlayer.Player1); this["F3"] = new Pawn(WhichPlayer.Player1);
this["G3"] = new Pawn(WhichPlayer.Player1); this["G3"] = new Pawn(WhichPlayer.Player1);
this["H3"] = new Pawn(WhichPlayer.Player1); this["H3"] = new Pawn(WhichPlayer.Player1);
this["I3"] = new Pawn(WhichPlayer.Player1); this["I3"] = new Pawn(WhichPlayer.Player1);
this["A4"] = null; this["A4"] = null;
this["B4"] = null; this["B4"] = null;
this["C4"] = null; this["C4"] = null;
this["D4"] = null; this["D4"] = null;
this["E4"] = null; this["E4"] = null;
this["F4"] = null; this["F4"] = null;
this["G4"] = null; this["G4"] = null;
this["H4"] = null; this["H4"] = null;
this["I4"] = null; this["I4"] = null;
this["A5"] = null; this["A5"] = null;
this["B5"] = null; this["B5"] = null;
this["C5"] = null; this["C5"] = null;
this["D5"] = null; this["D5"] = null;
this["E5"] = null; this["E5"] = null;
this["F5"] = null; this["F5"] = null;
this["G5"] = null; this["G5"] = null;
this["H5"] = null; this["H5"] = null;
this["I5"] = null; this["I5"] = null;
this["A6"] = null; this["A6"] = null;
this["B6"] = null; this["B6"] = null;
this["C6"] = null; this["C6"] = null;
this["D6"] = null; this["D6"] = null;
this["E6"] = null; this["E6"] = null;
this["F6"] = null; this["F6"] = null;
this["G6"] = null; this["G6"] = null;
this["H6"] = null; this["H6"] = null;
this["I6"] = null; this["I6"] = null;
this["A7"] = new Pawn(WhichPlayer.Player2); this["A7"] = new Pawn(WhichPlayer.Player2);
this["B7"] = new Pawn(WhichPlayer.Player2); this["B7"] = new Pawn(WhichPlayer.Player2);
this["C7"] = new Pawn(WhichPlayer.Player2); this["C7"] = new Pawn(WhichPlayer.Player2);
this["D7"] = new Pawn(WhichPlayer.Player2); this["D7"] = new Pawn(WhichPlayer.Player2);
this["E7"] = new Pawn(WhichPlayer.Player2); this["E7"] = new Pawn(WhichPlayer.Player2);
this["F7"] = new Pawn(WhichPlayer.Player2); this["F7"] = new Pawn(WhichPlayer.Player2);
this["G7"] = new Pawn(WhichPlayer.Player2); this["G7"] = new Pawn(WhichPlayer.Player2);
this["H7"] = new Pawn(WhichPlayer.Player2); this["H7"] = new Pawn(WhichPlayer.Player2);
this["I7"] = new Pawn(WhichPlayer.Player2); this["I7"] = new Pawn(WhichPlayer.Player2);
this["A8"] = null; this["A8"] = null;
this["B8"] = new Rook(WhichPlayer.Player2); this["B8"] = new Rook(WhichPlayer.Player2);
this["C8"] = null; this["C8"] = null;
this["D8"] = null; this["D8"] = null;
this["E8"] = null; this["E8"] = null;
this["F8"] = null; this["F8"] = null;
this["G8"] = null; this["G8"] = null;
this["H8"] = new Bishop(WhichPlayer.Player2); this["H8"] = new Bishop(WhichPlayer.Player2);
this["I8"] = null; this["I8"] = null;
this["A9"] = new Lance(WhichPlayer.Player2); this["A9"] = new Lance(WhichPlayer.Player2);
this["B9"] = new Knight(WhichPlayer.Player2); this["B9"] = new Knight(WhichPlayer.Player2);
this["C9"] = new SilverGeneral(WhichPlayer.Player2); this["C9"] = new SilverGeneral(WhichPlayer.Player2);
this["D9"] = new GoldGeneral(WhichPlayer.Player2); this["D9"] = new GoldGeneral(WhichPlayer.Player2);
this["E9"] = new King(WhichPlayer.Player2); this["E9"] = new King(WhichPlayer.Player2);
this["F9"] = new GoldGeneral(WhichPlayer.Player2); this["F9"] = new GoldGeneral(WhichPlayer.Player2);
this["G9"] = new SilverGeneral(WhichPlayer.Player2); this["G9"] = new SilverGeneral(WhichPlayer.Player2);
this["H9"] = new Knight(WhichPlayer.Player2); this["H9"] = new Knight(WhichPlayer.Player2);
this["I9"] = new Lance(WhichPlayer.Player2); this["I9"] = new Lance(WhichPlayer.Player2);
} }
} }
} }

View File

@@ -1,8 +1,8 @@
using Shogi.Domain.Pieces; using Shogi.Domain.ValueObjects;
namespace Shogi.Domain namespace Shogi.Domain
{ {
internal static class DomainExtensions internal static class DomainExtensions
{ {
public static bool IsKing(this Piece self) => self.WhichPiece == WhichPiece.King; public static bool IsKing(this Piece self) => self.WhichPiece == WhichPiece.King;

View File

@@ -1,9 +1,4 @@
using Shogi.Domain.Pieces; using System.Diagnostics;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Numerics;
namespace Shogi.Domain.Pathing namespace Shogi.Domain.Pathing
{ {

View File

@@ -1,47 +0,0 @@
using Shogi.Domain.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.Pieces
{
internal class Bishop : Piece
{
private static readonly ReadOnlyCollection<Path> BishopPaths = new(new List<Path>(4)
{
new Path(Direction.UpLeft, Distance.MultiStep),
new Path(Direction.UpRight, Distance.MultiStep),
new Path(Direction.DownLeft, Distance.MultiStep),
new Path(Direction.DownRight, Distance.MultiStep)
});
public static readonly ReadOnlyCollection<Path> PromotedBishopPaths = new(new List<Path>(8)
{
new Path(Direction.Up),
new Path(Direction.Left),
new Path(Direction.Right),
new Path(Direction.Down),
new Path(Direction.UpLeft, Distance.MultiStep),
new Path(Direction.UpRight, Distance.MultiStep),
new Path(Direction.DownLeft, Distance.MultiStep),
new Path(Direction.DownRight, Distance.MultiStep)
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
BishopPaths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public static readonly ReadOnlyCollection<Path> Player2PromotedPaths =
PromotedBishopPaths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public Bishop(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.Bishop, owner, isPromoted)
{
}
public override IEnumerable<Path> MoveSet => IsPromoted ? PromotedBishopPaths : BishopPaths;
}
}

View File

@@ -1,31 +0,0 @@
using Shogi.Domain.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.Pieces
{
internal class GoldGeneral : Piece
{
public static readonly ReadOnlyCollection<Path> Player1Paths = new(new List<Path>(6)
{
new Path(Direction.Up),
new Path(Direction.UpLeft),
new Path(Direction.UpRight),
new Path(Direction.Left),
new Path(Direction.Right),
new Path(Direction.Down)
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
Player1Paths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public GoldGeneral(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.GoldGeneral, owner, isPromoted)
{
}
public override IEnumerable<Path> MoveSet => Owner == WhichPlayer.Player1 ? Player1Paths : Player2Paths;
}
}

View File

@@ -1,27 +0,0 @@
using Shogi.Domain.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.Pieces
{
internal class King : Piece
{
internal static readonly ReadOnlyCollection<Path> KingPaths = new(new List<Path>(8)
{
new Path(Direction.Up),
new Path(Direction.Left),
new Path(Direction.Right),
new Path(Direction.Down),
new Path(Direction.UpLeft),
new Path(Direction.UpRight),
new Path(Direction.DownLeft),
new Path(Direction.DownRight)
});
public King(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.King, owner, isPromoted)
{
}
public override IEnumerable<Path> MoveSet => KingPaths;
}
}

View File

@@ -1,32 +0,0 @@
using Shogi.Domain.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.Pieces
{
internal class Knight : Piece
{
public static readonly ReadOnlyCollection<Path> Player1Paths = new(new List<Path>(2)
{
new Path(Direction.KnightLeft),
new Path(Direction.KnightRight)
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
Player1Paths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public Knight(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.Knight, owner, isPromoted)
{
}
public override ReadOnlyCollection<Path> MoveSet => Owner switch
{
WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths,
WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths,
_ => throw new NotImplementedException(),
};
}
}

View File

@@ -1,31 +0,0 @@
using Shogi.Domain.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.Pieces
{
internal class Lance : Piece
{
public static readonly ReadOnlyCollection<Path> Player1Paths = new(new List<Path>(1)
{
new Path(Direction.Up, Distance.MultiStep),
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
Player1Paths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public Lance(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.Lance, owner, isPromoted)
{
}
public override ReadOnlyCollection<Path> MoveSet => Owner switch
{
WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths,
WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths,
_ => throw new NotImplementedException(),
};
}
}

View File

@@ -1,31 +0,0 @@
using Shogi.Domain.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.Pieces
{
internal class Pawn : Piece
{
public static readonly ReadOnlyCollection<Path> Player1Paths = new(new List<Path>(1)
{
new Path(Direction.Up)
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
Player1Paths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public Pawn(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.Pawn, owner, isPromoted)
{
}
public override ReadOnlyCollection<Path> MoveSet => Owner switch
{
WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths,
WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths,
_ => throw new NotImplementedException(),
};
}
}

View File

@@ -1,95 +0,0 @@
using Shogi.Domain.Pathing;
using System.Diagnostics;
namespace Shogi.Domain.Pieces
{
[DebuggerDisplay("{WhichPiece} {Owner}")]
public abstract class Piece
{
/// <summary>
/// Creates a clone of an existing piece.
/// </summary>
public static Piece CreateCopy(Piece piece) => Create(piece.WhichPiece, piece.Owner, piece.IsPromoted);
public static Piece Create(WhichPiece piece, WhichPlayer owner, bool isPromoted = false)
{
return piece switch
{
WhichPiece.King => new King(owner, isPromoted),
WhichPiece.GoldGeneral => new GoldGeneral(owner, isPromoted),
WhichPiece.SilverGeneral => new SilverGeneral(owner, isPromoted),
WhichPiece.Bishop => new Bishop(owner, isPromoted),
WhichPiece.Rook => new Rook(owner, isPromoted),
WhichPiece.Knight => new Knight(owner, isPromoted),
WhichPiece.Lance => new Lance(owner, isPromoted),
WhichPiece.Pawn => new Pawn(owner, isPromoted),
_ => throw new ArgumentException($"Unknown {nameof(WhichPiece)} when cloning a {nameof(Piece)}.")
};
}
public abstract IEnumerable<Path> MoveSet { get; }
public WhichPiece WhichPiece { get; }
public WhichPlayer Owner { get; private set; }
public bool IsPromoted { get; private set; }
public bool IsUpsideDown => Owner == WhichPlayer.Player2;
protected Piece(WhichPiece piece, WhichPlayer owner, bool isPromoted = false)
{
WhichPiece = piece;
Owner = owner;
IsPromoted = isPromoted;
}
public bool CanPromote => !IsPromoted
&& WhichPiece != WhichPiece.King
&& WhichPiece != WhichPiece.GoldGeneral;
public void Promote() => IsPromoted = CanPromote;
/// <summary>
/// Prep the piece for capture by changing ownership and demoting.
/// </summary>
public void Capture(WhichPlayer newOwner)
{
Owner = newOwner;
IsPromoted = false;
}
/// <summary>
/// Respecting the move-set of the Piece, collect all positions from start to end.
/// Useful if you need to iterate a move-set.
/// </summary>
/// <param name="start"></param>
/// <param name="end"></param>
/// <returns>An empty list if the piece cannot legally traverse from start to end. Otherwise, a list of positions.</returns>
public IEnumerable<Vector2> GetPathFromStartToEnd(Vector2 start, Vector2 end)
{
var steps = new List<Vector2>(10);
var path = this.MoveSet.GetNearestPath(start, end);
var position = start;
while (Vector2.Distance(start, position) < Vector2.Distance(start, end))
{
position += path.Direction;
steps.Add(position);
if (path.Distance == Distance.OneStep) break;
}
if (position == end)
{
return steps;
}
return Array.Empty<Vector2>();
}
/// <summary>
/// Get all positions this piece could move to from the currentPosition, respecting the move-set of this piece.
/// </summary>
/// <param name="currentPosition"></param>
/// <returns>A list of positions the piece could move to.</returns>
public IEnumerable<Vector2> GetPossiblePositions(Vector2 currentPosition)
{
throw new NotImplementedException();
}
}
}

View File

@@ -1,52 +0,0 @@
using Shogi.Domain.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.Pieces
{
public sealed class Rook : Piece
{
public static readonly ReadOnlyCollection<Path> Player1Paths = new(new List<Path>(4)
{
new Path(Direction.Up, Distance.MultiStep),
new Path(Direction.Left, Distance.MultiStep),
new Path(Direction.Right, Distance.MultiStep),
new Path(Direction.Down, Distance.MultiStep)
});
private static readonly ReadOnlyCollection<Path> PromotedPlayer1Paths = new(new List<Path>(8)
{
new Path(Direction.Up, Distance.MultiStep),
new Path(Direction.Left, Distance.MultiStep),
new Path(Direction.Right, Distance.MultiStep),
new Path(Direction.Down, Distance.MultiStep),
new Path(Direction.UpLeft),
new Path(Direction.UpRight),
new Path(Direction.DownLeft),
new Path(Direction.DownRight)
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
Player1Paths
.Select(m => m.Invert())
.ToList()
.AsReadOnly();
public static readonly ReadOnlyCollection<Path> Player2PromotedPaths =
PromotedPlayer1Paths
.Select(m => m.Invert())
.ToList()
.AsReadOnly();
public Rook(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.Rook, owner, isPromoted)
{
}
public override ReadOnlyCollection<Path> MoveSet => Owner switch
{
WhichPlayer.Player1 => IsPromoted ? PromotedPlayer1Paths : Player1Paths,
WhichPlayer.Player2 => IsPromoted ? Player2PromotedPaths : Player2Paths,
_ => throw new NotImplementedException(),
};
}
}

View File

@@ -1,35 +0,0 @@
using Shogi.Domain.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.Pieces
{
internal class SilverGeneral : Piece
{
public static readonly ReadOnlyCollection<Path> Player1Paths = new(new List<Path>(4)
{
new Path(Direction.Up),
new Path(Direction.UpLeft),
new Path(Direction.UpRight),
new Path(Direction.DownLeft),
new Path(Direction.DownRight)
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
Player1Paths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public SilverGeneral(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.SilverGeneral, owner, isPromoted)
{
}
public override ReadOnlyCollection<Path> MoveSet => Owner switch
{
WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths,
WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths,
_ => throw new NotImplementedException(),
};
}
}

View File

@@ -1,257 +0,0 @@
using Shogi.Domain.Pieces;
using System.Text;
namespace Shogi.Domain
{
/// <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 sealed class Session
{
private readonly Tuple<string, string> Players;
private readonly BoardState boardState;
private readonly StandardRules rules;
private readonly SessionMetadata metadata;
public Session() : this(new BoardState())
{
}
public Session(BoardState state) : this(state, new SessionMetadata(string.Empty, false, string.Empty, string.Empty))
{
}
public Session(BoardState state, SessionMetadata metadata)
{
rules = new StandardRules(state);
boardState = state;
this.metadata = metadata;
Players = new(string.Empty, string.Empty);
}
public string Name => metadata.Name;
public string Player1Name => metadata.Player1;
public string? Player2Name => metadata.Player2;
public IDictionary<string, Piece?> BoardState => boardState.State;
public IList<Piece> Player1Hand => boardState.Player1Hand;
public IList<Piece> Player2Hand => boardState.Player2Hand;
public WhichPlayer? InCheck => boardState.InCheck;
public bool IsCheckMate => boardState.IsCheckmate;
/// <summary>
/// Move a piece from a board position to another board position, potentially capturing an opponents piece. Respects all rules of the game.
/// </summary>
/// <remarks>
/// The strategy involves simulating a move on a throw-away board state that can be used to
/// validate legal vs illegal moves without having to worry about reverting board state.
/// </remarks>
/// <exception cref="InvalidOperationException"></exception>
public void Move(string from, string to, bool isPromotion)
{
var simulationState = new BoardState(boardState);
var simulation = new StandardRules(simulationState);
var moveResult = simulation.Move(from, to, isPromotion);
if (!moveResult.Success)
{
throw new InvalidOperationException(moveResult.Reason);
}
// If already in check, assert the move that resulted in check no longer results in check.
if (boardState.InCheck == boardState.WhoseTurn
&& simulation.IsOpposingKingThreatenedByPosition(boardState.PreviousMoveTo))
{
throw new InvalidOperationException("Unable to move because you are still in check.");
}
var otherPlayer = boardState.WhoseTurn == WhichPlayer.Player1 ? WhichPlayer.Player2 : WhichPlayer.Player1;
if (simulation.IsPlayerInCheckAfterMove())
{
throw new InvalidOperationException("Illegal move. This move places you in check.");
}
_ = rules.Move(from, to, isPromotion);
if (rules.IsOpponentInCheckAfterMove())
{
boardState.InCheck = otherPlayer;
if (rules.IsOpponentInCheckMate())
{
boardState.IsCheckmate = true;
}
}
else
{
boardState.InCheck = null;
}
boardState.WhoseTurn = otherPlayer;
}
public void Move(WhichPiece pieceInHand, string to)
{
var index = boardState.ActivePlayerHand.FindIndex(p => p.WhichPiece == pieceInHand);
if (index == -1)
{
throw new InvalidOperationException($"{pieceInHand} does not exist in the hand.");
}
if (boardState[to] != null)
{
throw new InvalidOperationException("Illegal placement of piece from the hand. Destination is not empty.");
}
var toVector = Notation.FromBoardNotation(to);
switch (pieceInHand)
{
case WhichPiece.Knight:
{
// Knight cannot be placed onto the farthest two ranks from the hand.
if ((boardState.WhoseTurn == WhichPlayer.Player1 && toVector.Y > 6)
|| (boardState.WhoseTurn == WhichPlayer.Player2 && toVector.Y < 2))
{
throw new InvalidOperationException("Illegal move. Knight has no valid moves after placement.");
}
break;
}
case WhichPiece.Lance:
case WhichPiece.Pawn:
{
// Lance and Pawn cannot be placed onto the farthest rank from the hand.
if ((boardState.WhoseTurn == WhichPlayer.Player1 && toVector.Y == 8)
|| (boardState.WhoseTurn == WhichPlayer.Player2 && toVector.Y == 0))
{
throw new InvalidOperationException($"Illegal move. {pieceInHand} has no valid moves after placement.");
}
break;
}
}
var tempBoard = new BoardState(boardState);
var simulation = new StandardRules(tempBoard);
var moveResult = simulation.Move(pieceInHand, to);
if (!moveResult.Success)
{
throw new InvalidOperationException(moveResult.Reason);
}
var otherPlayer = tempBoard.WhoseTurn == WhichPlayer.Player1 ? WhichPlayer.Player2 : WhichPlayer.Player1;
if (boardState.InCheck == boardState.WhoseTurn)
{
//if (simulation.IsPlayerInCheckAfterMove(boardState.PreviousMoveTo, toVector, boardState.WhoseTurn))
//{
// throw new InvalidOperationException("Illegal move. You're still in check!");
//}
}
var kingPosition = otherPlayer == WhichPlayer.Player1 ? tempBoard.Player1KingPosition : tempBoard.Player2KingPosition;
//if (simulation.IsPlayerInCheckAfterMove(toVector, kingPosition, otherPlayer))
//{
//}
//rules.Move(from, to, isPromotion);
//if (rules.IsPlayerInCheckAfterMove(fromVector, toVector, otherPlayer))
//{
// board.InCheck = otherPlayer;
// board.IsCheckmate = rules.EvaluateCheckmate();
//}
//else
//{
// board.InCheck = null;
//}
boardState.WhoseTurn = otherPlayer;
}
/// <summary>
/// Prints a ASCII representation of the board for debugging board state.
/// </summary>
/// <returns></returns>
public string ToStringStateAsAscii()
{
var builder = new StringBuilder();
builder.Append(" ");
builder.Append("Player 2(.)");
builder.AppendLine();
for (var rank = 8; rank >= 0; rank--)
{
// Horizontal line
builder.Append(" - ");
for (var file = 0; file < 8; file++) builder.Append("- - ");
builder.Append("- -");
// Print Rank ruler.
builder.AppendLine();
builder.Append($"{rank + 1} ");
// Print pieces.
builder.Append(" |");
for (var x = 0; x < 9; x++)
{
var piece = boardState[x, rank];
if (piece == null)
{
builder.Append(" ");
}
else
{
builder.AppendFormat("{0}", ToAscii(piece));
}
builder.Append('|');
}
builder.AppendLine();
}
// Horizontal line
builder.Append(" - ");
for (var x = 0; x < 8; x++) builder.Append("- - ");
builder.Append("- -");
builder.AppendLine();
builder.Append(" ");
builder.Append("Player 1");
builder.AppendLine();
builder.AppendLine();
// Print File ruler.
builder.Append(" ");
builder.Append(" A B C D E F G H I ");
return builder.ToString();
}
/// <summary>
///
/// </summary>
/// <param name="piece"></param>
/// <returns>
/// A string with three characters.
/// The first character indicates promotion status.
/// The second character indicates piece.
/// The third character indicates ownership.
/// </returns>
public static string ToAscii(Piece piece)
{
var builder = new StringBuilder();
if (piece.IsPromoted) builder.Append('^');
else builder.Append(' ');
var name = piece.WhichPiece switch
{
WhichPiece.King => "K",
WhichPiece.GoldGeneral => "G",
WhichPiece.SilverGeneral => "S",
WhichPiece.Bishop => "B",
WhichPiece.Rook => "R",
WhichPiece.Knight => "k",
WhichPiece.Lance => "L",
WhichPiece.Pawn => "P",
_ => throw new ArgumentException($"Unknown value for {nameof(WhichPiece)}."),
};
builder.Append(name);
if (piece.Owner == WhichPlayer.Player2) builder.Append('.');
else builder.Append(' ');
return builder.ToString();
}
}
}

View File

@@ -1,28 +0,0 @@
namespace Shogi.Domain
{
/// <summary>
/// A representation of a Session without the board and game-rules.
/// </summary>
public class SessionMetadata
{
public string Name { get; }
public string Player1 { get; }
public string? Player2 { get; private set; }
public bool IsPrivate { get; }
public SessionMetadata(string name, bool isPrivate, string player1, string? player2 = null)
{
Name = name;
IsPrivate = isPrivate;
Player1 = player1;
Player2 = player2;
}
public void SetPlayer2(string user)
{
Player2 = user;
}
public bool IsSeated(string playerName) => playerName == Player1 || playerName == Player2;
}
}

View File

@@ -7,10 +7,14 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Using Include="System"/> <Using Include="System" />
<Using Include="System.Collections.Generic"/> <Using Include="System.Collections.Generic" />
<Using Include="System.Linq"/> <Using Include="System.Linq" />
<Using Include="System.Numerics"/> <Using Include="System.Numerics" />
</ItemGroup>
<ItemGroup>
<Folder Include="Entities\" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,10 +1,10 @@
using Shogi.Domain.Pathing; using Shogi.Domain.Pathing;
using Shogi.Domain.Pieces; using Shogi.Domain.ValueObjects;
using BoardTile = System.Collections.Generic.KeyValuePair<System.Numerics.Vector2, Shogi.Domain.Pieces.Piece>; using BoardTile = System.Collections.Generic.KeyValuePair<System.Numerics.Vector2, Shogi.Domain.ValueObjects.Piece>;
namespace Shogi.Domain namespace Shogi.Domain
{ {
internal class StandardRules internal class StandardRules
{ {
private readonly BoardState boardState; private readonly BoardState boardState;

View File

@@ -0,0 +1,47 @@
using Shogi.Domain.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.ValueObjects
{
internal record class Bishop : Piece
{
private static readonly ReadOnlyCollection<Path> BishopPaths = new(new List<Path>(4)
{
new Path(Direction.UpLeft, Distance.MultiStep),
new Path(Direction.UpRight, Distance.MultiStep),
new Path(Direction.DownLeft, Distance.MultiStep),
new Path(Direction.DownRight, Distance.MultiStep)
});
public static readonly ReadOnlyCollection<Path> PromotedBishopPaths = new(new List<Path>(8)
{
new Path(Direction.Up),
new Path(Direction.Left),
new Path(Direction.Right),
new Path(Direction.Down),
new Path(Direction.UpLeft, Distance.MultiStep),
new Path(Direction.UpRight, Distance.MultiStep),
new Path(Direction.DownLeft, Distance.MultiStep),
new Path(Direction.DownRight, Distance.MultiStep)
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
BishopPaths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public static readonly ReadOnlyCollection<Path> Player2PromotedPaths =
PromotedBishopPaths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public Bishop(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.Bishop, owner, isPromoted)
{
}
public override IEnumerable<Path> MoveSet => IsPromoted ? PromotedBishopPaths : BishopPaths;
}
}

View File

@@ -0,0 +1,31 @@
using Shogi.Domain.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.ValueObjects
{
internal record class GoldGeneral : Piece
{
public static readonly ReadOnlyCollection<Path> Player1Paths = new(new List<Path>(6)
{
new Path(Direction.Up),
new Path(Direction.UpLeft),
new Path(Direction.UpRight),
new Path(Direction.Left),
new Path(Direction.Right),
new Path(Direction.Down)
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
Player1Paths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public GoldGeneral(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.GoldGeneral, owner, isPromoted)
{
}
public override IEnumerable<Path> MoveSet => Owner == WhichPlayer.Player1 ? Player1Paths : Player2Paths;
}
}

View File

@@ -0,0 +1,27 @@
using Shogi.Domain.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.ValueObjects
{
internal record class King : Piece
{
internal static readonly ReadOnlyCollection<Path> KingPaths = new(new List<Path>(8)
{
new Path(Direction.Up),
new Path(Direction.Left),
new Path(Direction.Right),
new Path(Direction.Down),
new Path(Direction.UpLeft),
new Path(Direction.UpRight),
new Path(Direction.DownLeft),
new Path(Direction.DownRight)
});
public King(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.King, owner, isPromoted)
{
}
public override IEnumerable<Path> MoveSet => KingPaths;
}
}

View File

@@ -0,0 +1,32 @@
using Shogi.Domain.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.ValueObjects
{
internal record class Knight : Piece
{
public static readonly ReadOnlyCollection<Path> Player1Paths = new(new List<Path>(2)
{
new Path(Direction.KnightLeft),
new Path(Direction.KnightRight)
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
Player1Paths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public Knight(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.Knight, owner, isPromoted)
{
}
public override ReadOnlyCollection<Path> MoveSet => Owner switch
{
WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths,
WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths,
_ => throw new NotImplementedException(),
};
}
}

View File

@@ -0,0 +1,31 @@
using Shogi.Domain.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.ValueObjects
{
internal record class Lance : Piece
{
public static readonly ReadOnlyCollection<Path> Player1Paths = new(new List<Path>(1)
{
new Path(Direction.Up, Distance.MultiStep),
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
Player1Paths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public Lance(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.Lance, owner, isPromoted)
{
}
public override ReadOnlyCollection<Path> MoveSet => Owner switch
{
WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths,
WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths,
_ => throw new NotImplementedException(),
};
}
}

View File

@@ -0,0 +1,31 @@
using Shogi.Domain.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.ValueObjects
{
internal record class Pawn : Piece
{
public static readonly ReadOnlyCollection<Path> Player1Paths = new(new List<Path>(1)
{
new Path(Direction.Up)
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
Player1Paths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public Pawn(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.Pawn, owner, isPromoted)
{
}
public override ReadOnlyCollection<Path> MoveSet => Owner switch
{
WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths,
WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths,
_ => throw new NotImplementedException(),
};
}
}

View File

@@ -0,0 +1,95 @@
using Shogi.Domain.Pathing;
using System.Diagnostics;
namespace Shogi.Domain.ValueObjects
{
[DebuggerDisplay("{WhichPiece} {Owner}")]
public abstract record class Piece
{
/// <summary>
/// Creates a clone of an existing piece.
/// </summary>
public static Piece CreateCopy(Piece piece) => Create(piece.WhichPiece, piece.Owner, piece.IsPromoted);
public static Piece Create(WhichPiece piece, WhichPlayer owner, bool isPromoted = false)
{
return piece switch
{
WhichPiece.King => new King(owner, isPromoted),
WhichPiece.GoldGeneral => new GoldGeneral(owner, isPromoted),
WhichPiece.SilverGeneral => new SilverGeneral(owner, isPromoted),
WhichPiece.Bishop => new Bishop(owner, isPromoted),
WhichPiece.Rook => new Rook(owner, isPromoted),
WhichPiece.Knight => new Knight(owner, isPromoted),
WhichPiece.Lance => new Lance(owner, isPromoted),
WhichPiece.Pawn => new Pawn(owner, isPromoted),
_ => throw new ArgumentException($"Unknown {nameof(WhichPiece)} when cloning a {nameof(Piece)}.")
};
}
public abstract IEnumerable<Path> MoveSet { get; }
public WhichPiece WhichPiece { get; }
public WhichPlayer Owner { get; private set; }
public bool IsPromoted { get; private set; }
public bool IsUpsideDown => Owner == WhichPlayer.Player2;
protected Piece(WhichPiece piece, WhichPlayer owner, bool isPromoted = false)
{
WhichPiece = piece;
Owner = owner;
IsPromoted = isPromoted;
}
public bool CanPromote => !IsPromoted
&& WhichPiece != WhichPiece.King
&& WhichPiece != WhichPiece.GoldGeneral;
public void Promote() => IsPromoted = CanPromote;
/// <summary>
/// Prep the piece for capture by changing ownership and demoting.
/// </summary>
public void Capture(WhichPlayer newOwner)
{
Owner = newOwner;
IsPromoted = false;
}
/// <summary>
/// Respecting the move-set of the Piece, collect all positions from start to end.
/// Useful if you need to iterate a move-set.
/// </summary>
/// <param name="start"></param>
/// <param name="end"></param>
/// <returns>An empty list if the piece cannot legally traverse from start to end. Otherwise, a list of positions.</returns>
public IEnumerable<Vector2> GetPathFromStartToEnd(Vector2 start, Vector2 end)
{
var steps = new List<Vector2>(10);
var path = MoveSet.GetNearestPath(start, end);
var position = start;
while (Vector2.Distance(start, position) < Vector2.Distance(start, end))
{
position += path.Direction;
steps.Add(position);
if (path.Distance == Distance.OneStep) break;
}
if (position == end)
{
return steps;
}
return Array.Empty<Vector2>();
}
/// <summary>
/// Get all positions this piece could move to from the currentPosition, respecting the move-set of this piece.
/// </summary>
/// <param name="currentPosition"></param>
/// <returns>A list of positions the piece could move to.</returns>
public IEnumerable<Vector2> GetPossiblePositions(Vector2 currentPosition)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,51 @@
using Shogi.Domain.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.ValueObjects;
public record class Rook : Piece
{
public static readonly ReadOnlyCollection<Path> Player1Paths = new(new List<Path>(4)
{
new Path(Direction.Up, Distance.MultiStep),
new Path(Direction.Left, Distance.MultiStep),
new Path(Direction.Right, Distance.MultiStep),
new Path(Direction.Down, Distance.MultiStep)
});
private static readonly ReadOnlyCollection<Path> PromotedPlayer1Paths = new(new List<Path>(8)
{
new Path(Direction.Up, Distance.MultiStep),
new Path(Direction.Left, Distance.MultiStep),
new Path(Direction.Right, Distance.MultiStep),
new Path(Direction.Down, Distance.MultiStep),
new Path(Direction.UpLeft),
new Path(Direction.UpRight),
new Path(Direction.DownLeft),
new Path(Direction.DownRight)
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
Player1Paths
.Select(m => m.Invert())
.ToList()
.AsReadOnly();
public static readonly ReadOnlyCollection<Path> Player2PromotedPaths =
PromotedPlayer1Paths
.Select(m => m.Invert())
.ToList()
.AsReadOnly();
public Rook(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.Rook, owner, isPromoted)
{
}
public override ReadOnlyCollection<Path> MoveSet => Owner switch
{
WhichPlayer.Player1 => IsPromoted ? PromotedPlayer1Paths : Player1Paths,
WhichPlayer.Player2 => IsPromoted ? Player2PromotedPaths : Player2Paths,
_ => throw new NotImplementedException(),
};
}

View File

@@ -0,0 +1,35 @@
using Shogi.Domain.Pathing;
using System.Collections.ObjectModel;
namespace Shogi.Domain.ValueObjects
{
internal record class SilverGeneral : Piece
{
public static readonly ReadOnlyCollection<Path> Player1Paths = new(new List<Path>(4)
{
new Path(Direction.Up),
new Path(Direction.UpLeft),
new Path(Direction.UpRight),
new Path(Direction.DownLeft),
new Path(Direction.DownRight)
});
public static readonly ReadOnlyCollection<Path> Player2Paths =
Player1Paths
.Select(p => p.Invert())
.ToList()
.AsReadOnly();
public SilverGeneral(WhichPlayer owner, bool isPromoted = false)
: base(WhichPiece.SilverGeneral, owner, isPromoted)
{
}
public override ReadOnlyCollection<Path> MoveSet => Owner switch
{
WhichPlayer.Player1 => IsPromoted ? GoldGeneral.Player1Paths : Player1Paths,
WhichPlayer.Player2 => IsPromoted ? GoldGeneral.Player2Paths : Player2Paths,
_ => throw new NotImplementedException(),
};
}
}

View File

@@ -0,0 +1,217 @@
using Shogi.Api.Managers;
using Shogi.Api.Repositories;
using Shogi.Contracts.Api;
using Shogi.Contracts.Socket;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Data.SqlClient;
using Shogi.Contracts.Types;
using Shogi.Api.Extensions;
namespace Shogi.Api.Controllers;
[ApiController]
[Route("[controller]")]
[Authorize]
public class SessionController : ControllerBase
{
private readonly ISocketConnectionManager communicationManager;
private readonly IModelMapper mapper;
private readonly ISessionRepository sessionRepository;
private readonly IQueryRespository queryRespository;
public SessionController(
ISocketConnectionManager communicationManager,
IModelMapper mapper,
ISessionRepository sessionRepository,
IQueryRespository queryRespository)
{
this.communicationManager = communicationManager;
this.mapper = mapper;
this.sessionRepository = sessionRepository;
this.queryRespository = queryRespository;
}
[HttpPost]
public async Task<IActionResult> CreateSession([FromBody] CreateSessionCommand request)
{
var userId = User.GetShogiUserId();
if (string.IsNullOrWhiteSpace(userId)) return this.Unauthorized();
var session = new Domain.Session(request.Name, Domain.BoardState.StandardStarting, userId);
try
{
await sessionRepository.CreateSession(session);
}
catch (SqlException)
{
return this.Conflict();
}
await communicationManager.BroadcastToAll(new SessionCreatedSocketMessage());
return CreatedAtAction(nameof(CreateSession), new { sessionName = request.Name }, null);
}
//[HttpPost("{sessionName}/Move")]
//public async Task<IActionResult> MovePiece([FromRoute] string sessionName, [FromBody] MovePieceCommand request)
//{
// var user = await gameboardManager.ReadUser(User);
// var session = await gameboardRepository.ReadSession(sessionName);
// if (session == null)
// {
// return NotFound();
// }
// if (user == null || (session.Player1 != user.Id && session.Player2 != user.Id))
// {
// return Forbid("User is not seated at this game.");
// }
// try
// {
// var move = request.Move;
// if (move.PieceFromCaptured.HasValue)
// session.Move(mapper.Map(move.PieceFromCaptured.Value), move.To);
// else if (!string.IsNullOrWhiteSpace(move.From))
// session.Move(move.From, move.To, move.IsPromotion);
// await gameboardRepository.CreateBoardState(session);
// await communicationManager.BroadcastToPlayers(
// new MoveResponse
// {
// SessionName = session.Name,
// PlayerName = user.Id
// },
// session.Player1,
// session.Player2);
// return Ok();
// }
// catch (InvalidOperationException ex)
// {
// return Conflict(ex.Message);
// }
//}
// 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.SocketAction.CreateGame)
// {
// Game = model.ToServiceModel(),
// PlayerName =
// }
// var task = request.IsPrivate
// ? communicationManager.BroadcastToPlayers(response, userName)
// : communicationManager.BroadcastToAll(response);
// return new CreatedResult("", null);
// }
// return new ConflictResult();
//}
//[HttpGet("{sessionName}")]
//[AllowAnonymous]
//public async Task<IActionResult> GetSession([FromRoute] string sessionName)
//{
// var user = await ReadUserOrThrow();
// var session = await gameboardRepository.ReadSession(sessionName);
// if (session == null)
// {
// return NotFound();
// }
// var playerPerspective = session.Player2 == user.Id
// ? WhichPlayer.Player2
// : WhichPlayer.Player1;
// var response = new ReadSessionResponse
// {
// Session = new Session
// {
// BoardState = new BoardState
// {
// Board = mapper.Map(session.BoardState.State),
// Player1Hand = session.BoardState.Player1Hand.Select(mapper.Map).ToList(),
// Player2Hand = session.BoardState.Player2Hand.Select(mapper.Map).ToList(),
// PlayerInCheck = mapper.Map(session.BoardState.InCheck)
// },
// SessionName = session.Name,
// Player1 = session.Player1,
// Player2 = session.Player2
// }
// };
// return Ok(response);
//}
[HttpGet]
[AllowAnonymous]
public async Task<ActionResult<ReadAllSessionsResponse>> GetSessions()
{
var sessions = await this.queryRespository.ReadAllSessionsMetadata();
return Ok(new ReadAllSessionsResponse
{
PlayerHasJoinedSessions = Array.Empty<SessionMetadata>(),
AllOtherSessions = sessions.ToList()
});
}
//[HttpPut("{sessionName}")]
//public async Task<IActionResult> PutJoinSession([FromRoute] string sessionName)
//{
// var user = await ReadUserOrThrow();
// var session = await gameboardRepository.ReadSessionMetaData(sessionName);
// 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.Id);
// await gameboardRepository.UpdateSession(session);
// var opponentName = user.Id == session.Player1
// ? session.Player2!
// : session.Player1;
// await communicationManager.BroadcastToPlayers(new JoinSessionResponse
// {
// SessionName = session.Name,
// PlayerName = user.Id
// }, opponentName);
// return Ok();
//}
//[Authorize(Roles = "Admin")]
//[HttpDelete("{sessionName}")]
//public async Task<IActionResult> DeleteSession([FromRoute] string sessionName)
//{
// var user = await ReadUserOrThrow();
// if (user.IsAdmin)
// {
// return Ok();
// }
// else
// {
// 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

@@ -0,0 +1,108 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Shogi.Contracts.Api;
using Shogi.Api.Extensions;
using Shogi.Api.Managers;
using Shogi.Api.Models;
using Shogi.Api.Repositories;
using System.Security.Claims;
namespace Shogi.Api.Controllers;
[ApiController]
[Route("[controller]")]
[Authorize]
public class UserController : ControllerBase
{
private readonly ISocketTokenCache tokenCache;
private readonly ISocketConnectionManager connectionManager;
private readonly IUserRepository userRepository;
private readonly IShogiUserClaimsTransformer claimsTransformation;
private readonly AuthenticationProperties authenticationProps;
public UserController(
ILogger<UserController> logger,
ISocketTokenCache tokenCache,
ISocketConnectionManager connectionManager,
IUserRepository userRepository,
IShogiUserClaimsTransformer claimsTransformation)
{
this.tokenCache = tokenCache;
this.connectionManager = connectionManager;
this.userRepository = userRepository;
this.claimsTransformation = claimsTransformation;
authenticationProps = new AuthenticationProperties
{
AllowRefresh = true,
IsPersistent = true
};
}
[HttpPut("GuestLogout")]
public async Task<IActionResult> GuestLogout()
{
var signoutTask = HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var userId = User?.GetGuestUserId();
if (!string.IsNullOrEmpty(userId))
{
connectionManager.Unsubscribe(userId);
}
await signoutTask;
return Ok();
}
//[HttpGet("Token")]
//public async Task<IActionResult> GetToken()
//{
// var user = await gameboardManager.ReadUser(User);
// if (user == null)
// {
// await gameboardManager.CreateUser(User);
// user = await gameboardManager.ReadUser(User);
// }
// if (user == null)
// {
// return Unauthorized();
// }
// var token = tokenCache.GenerateToken(user.Id);
// return new JsonResult(new CreateTokenResponse(token));
//}
[AllowAnonymous]
[HttpGet("LoginAsGuest")]
public async Task<IActionResult> GuestLogin()
{
var principal = await this.claimsTransformation.CreateClaimsFromGuestPrincipal(User);
if (principal != null)
{
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
authenticationProps
);
}
return Ok();
}
[HttpGet("GuestToken")]
public IActionResult GetGuestToken()
{
var id = User.GetGuestUserId();
var displayName = User.DisplayName();
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(displayName))
{
var token = tokenCache.GenerateToken(User.GetGuestUserId()!);
return this.Ok(new CreateGuestTokenResponse(id, displayName, token));
}
return this.Unauthorized();
}
}

View File

@@ -1,4 +1,4 @@
namespace Gameboard.ShogiUI.Sockets namespace Shogi.Api
{ {
namespace anonymous_session.Middlewares namespace anonymous_session.Middlewares
{ {
@@ -9,11 +9,11 @@
/// <summary> /// <summary>
/// TODO: Use this example in the guest session logic instead of custom claims. /// TODO: Use this example in the guest session logic instead of custom claims.
/// </summary> /// </summary>
public class AnonymousSessionMiddleware public class ExampleAnonymousSessionMiddleware
{ {
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
public AnonymousSessionMiddleware(RequestDelegate next) public ExampleAnonymousSessionMiddleware(RequestDelegate next)
{ {
_next = next; _next = next;
} }

View File

@@ -0,0 +1,30 @@
using System.Security.Claims;
namespace Shogi.Api.Extensions;
public static class Extensions
{
private static readonly string MsalUsernameClaim = "preferred_username";
public static string? GetGuestUserId(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 IsMicrosoft(this ClaimsPrincipal self)
{
return self.HasClaim(c => c.Type == MsalUsernameClaim);
}
public static string? GetMicrosoftUserId(this ClaimsPrincipal self)
{
return self.Claims.FirstOrDefault(c => c.Type == MsalUsernameClaim)?.Value;
}
public static string? GetShogiUserId(this ClaimsPrincipal self) => self.IsMicrosoft() ? self.GetMicrosoftUserId() : self.GetGuestUserId();
}

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