This commit is contained in:
2022-11-09 08:56:54 -06:00
parent 2241ab23fe
commit 3257b420e9
17 changed files with 601 additions and 538 deletions

View File

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

@@ -1,6 +1,7 @@
CREATE PROCEDURE [session].[CreateSession]
@InitialBoardStateDocument [session].[JsonDocument],
@Player1Name [user].[UserName]
@Name [session].[SessionName],
@Player1Name [user].[UserName],
@InitialBoardStateDocument [session].[JsonDocument]
AS
BEGIN
SET NOCOUNT ON

View File

@@ -1,17 +1,17 @@
CREATE TABLE [session].[Session]
(
Id BIGINT NOT NULL PRIMARY KEY IDENTITY,
Created DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
[Name] [session].[SessionName] UNIQUE,
Player1Id BIGINT NOT NULL,
Player2Id BIGINT NULL,
BoardState [session].[JsonDocument] NOT NULL,
[Name] AS JSON_VALUE(BoardState, '$.Name') UNIQUE,
Created DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
CONSTRAINT [BoardState must be json] CHECK (isjson(BoardState)=1),
CONSTRAINT FK_Player1_User FOREIGN KEY (Player1Id) REFERENCES [user].[User] (Id)
ON DELETE CASCADE
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)
CONSTRAINT FK_Player2_User FOREIGN KEY (Player2Id) REFERENCES [user].[User] (Id)
ON DELETE NO ACTION
ON UPDATE NO ACTION
)

View File

@@ -72,7 +72,6 @@
<Build Include="User\user.sql" />
<Build Include="Session\Tables\Session.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" />

View File

@@ -1,240 +1,27 @@
using Shogi.Domain.ValueObjects;
using System.Text;
namespace Shogi.Domain;
namespace Shogi.Domain.Aggregates;
/// <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 ShogiBoard
public class Session
{
private readonly StandardRules rules;
public Session(
string name,
string player1Name,
ShogiBoard board)
{
Name = name;
Player1 = player1Name;
Board = board;
}
public ShogiBoard(string name, BoardState initialState, string player1, string? player2 = null)
{
Name = name;
Player1 = player1;
Player2 = player2;
BoardState = initialState;
rules = new StandardRules(BoardState);
}
public string Name { get; }
public ShogiBoard Board { get; }
public string Player1 { get; }
public string? Player2 { get; private set; }
public BoardState BoardState { get; }
public string Name { 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();
}
public void AddPlayer2(string player2Name)
{
if (Player2 != null) throw new InvalidOperationException("Player 2 already exists while trying to add a second player.");
Player2 = player2Name;
}
}

View File

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

View File

@@ -1,10 +1,9 @@
using Shogi.Domain.Pathing;
using Shogi.Domain.ValueObjects;
using Shogi.Domain.ValueObjects;
using BoardTile = System.Collections.Generic.KeyValuePair<System.Numerics.Vector2, Shogi.Domain.ValueObjects.Piece>;
namespace Shogi.Domain
{
internal class StandardRules
internal class StandardRules
{
private readonly BoardState boardState;
@@ -55,7 +54,7 @@ namespace Shogi.Domain
boardState[to] = fromPiece;
boardState[from] = null;
boardState.RememberAsMostRecentMove(from, to);
boardState.PreviousMove = new Move(from, to);
var otherPlayer = boardState.WhoseTurn == WhichPlayer.Player1 ? WhichPlayer.Player2 : WhichPlayer.Player1;
boardState.WhoseTurn = otherPlayer;
@@ -121,16 +120,16 @@ namespace Shogi.Domain
/// </remarks>
internal bool IsPlayerInCheckAfterMove()
{
var previousMovedPiece = boardState[boardState.PreviousMoveTo];
if (previousMovedPiece == null) throw new ArgumentNullException(nameof(previousMovedPiece), $"No piece exists at position {boardState.PreviousMoveTo}.");
var previousMovedPiece = boardState[boardState.PreviousMove.To];
if (previousMovedPiece == null) throw new ArgumentNullException(nameof(previousMovedPiece), $"No piece exists at position {boardState.PreviousMove.To}.");
var kingPosition = previousMovedPiece.Owner == WhichPlayer.Player1 ? boardState.Player1KingPosition : boardState.Player2KingPosition;
var isCheck = false;
// Get line equation from king through the now-unoccupied location.
var direction = Vector2.Subtract(kingPosition, boardState.PreviousMoveFrom);
var direction = Vector2.Subtract(kingPosition, boardState.PreviousMove.From);
var slope = Math.Abs(direction.Y / direction.X);
var path = BoardState.GetPathAlongDirectionFromStartToEdgeOfBoard(boardState.PreviousMoveFrom, Vector2.Normalize(direction));
var path = BoardState.GetPathAlongDirectionFromStartToEdgeOfBoard(boardState.PreviousMove.From, Vector2.Normalize(direction));
var threat = boardState.QueryFirstPieceInPath(path);
if (threat == null || threat.Owner == previousMovedPiece.Owner) return false;
// If absolute slope is 45°, look for a bishop along the line.
@@ -165,7 +164,7 @@ namespace Shogi.Domain
return isCheck;
}
internal bool IsOpponentInCheckAfterMove() => IsOpposingKingThreatenedByPosition(boardState.PreviousMoveTo);
internal bool IsOpponentInCheckAfterMove() => IsOpposingKingThreatenedByPosition(boardState.PreviousMove.To);
internal bool IsOpposingKingThreatenedByPosition(Vector2 position)
{

View File

@@ -0,0 +1,8 @@
namespace Shogi.Domain.ValueObjects;
/// <summary>
/// Represents a single piece being moved by a player from <paramref name="From"/> to <paramref name="To"/>.
/// </summary>
public record struct Move(Vector2 From, Vector2 To)
{
}

View File

@@ -6,10 +6,6 @@ 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
@@ -54,7 +50,7 @@ namespace Shogi.Domain.ValueObjects
}
/// <summary>
/// Respecting the move-set of the Piece, collect all positions from start to end.
/// Respecting the move-set of the Piece, collect all positions along the shortest path from start to end.
/// Useful if you need to iterate a move-set.
/// </summary>
/// <param name="start"></param>

View File

@@ -0,0 +1,235 @@
using System.Text;
namespace Shogi.Domain.ValueObjects;
/// <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 ShogiBoard
{
private readonly StandardRules rules;
public ShogiBoard(BoardState initialState)
{
BoardState = initialState;
rules = new StandardRules(BoardState);
}
public BoardState BoardState { 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.PreviousMove.To))
{
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

@@ -19,17 +19,20 @@ public class SessionController : ControllerBase
private readonly IModelMapper mapper;
private readonly ISessionRepository sessionRepository;
private readonly IQueryRespository queryRespository;
private readonly ILogger<SessionController> logger;
public SessionController(
ISocketConnectionManager communicationManager,
IModelMapper mapper,
ISessionRepository sessionRepository,
IQueryRespository queryRespository)
IQueryRespository queryRespository,
ILogger<SessionController> logger)
{
this.communicationManager = communicationManager;
this.mapper = mapper;
this.sessionRepository = sessionRepository;
this.queryRespository = queryRespository;
this.logger = logger;
}
[HttpPost]
@@ -37,13 +40,17 @@ public class SessionController : ControllerBase
{
var userId = User.GetShogiUserId();
if (string.IsNullOrWhiteSpace(userId)) return this.Unauthorized();
var session = new Domain.ShogiBoard(request.Name, Domain.BoardState.StandardStarting, userId);
var session = new Domain.Aggregates.Session(
request.Name,
userId,
new Domain.ValueObjects.ShogiBoard(Domain.BoardState.StandardStarting));
try
{
await sessionRepository.CreateSession(session);
await sessionRepository.CreateShogiBoard(board, request.Name, userId);
}
catch (SqlException)
catch (SqlException e)
{
logger.LogError(exception: e, message: "Uh oh");
return this.Conflict();
}

View File

@@ -0,0 +1,22 @@
using Shogi.Domain;
using Shogi.Domain.ValueObjects;
using System.Collections.ObjectModel;
namespace Shogi.Api.Repositories.Dto;
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
public class BoardStateDto
{
public ReadOnlyDictionary<string, Piece?> State { get; set; }
public List<Piece> Player1Hand { get; set; }
public List<Piece> Player2Hand { get; set; }
public Move PreviousMove { get; }
public WhichPlayer WhoseTurn { get; set; }
public WhichPlayer? InCheck { get; set; }
public bool IsCheckmate { get; set; }
}
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.

View File

@@ -1,14 +1,14 @@
namespace Shogi.Api.Repositories.Dto
namespace Shogi.Api.Repositories.Dto;
/// <summary>
/// Useful with Dapper to read from database.
/// </summary>
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
public class SessionDto
{
/// <summary>
/// Useful with Dapper to read from database.
/// </summary>
public class SessionDto
{
public string Name { get; set; }
public string Player1 { get; set; }
public string Player2 { get; set; }
public bool GameOver { get; set; }
public string BoardState { get; set; }
}
public string Name { get; set; }
public string Player1 { get; set; }
public string Player2 { get; set; }
public BoardStateDto BoardState { get; set; }
}
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.

View File

@@ -21,19 +21,9 @@ public class QueryRepository : IQueryRespository
"session.ReadAllSessionsMetadata",
commandType: System.Data.CommandType.StoredProcedure);
}
public async Task<SessionMetadata?> ReadSession(string name)
{
using var connection = new SqlConnection(connectionString);
var results = await connection.QueryAsync<SessionDto>(
"session.ReadSession",
commandType: System.Data.CommandType.StoredProcedure);
return results.SingleOrDefault();
}
}
public interface IQueryRespository
{
Task<IEnumerable<SessionMetadata>> ReadAllSessionsMetadata();
Task<SessionMetadata?> ReadSession(string name);
}

View File

@@ -1,6 +1,8 @@
using Dapper;
using Shogi.Api.Repositories.Dto;
using Shogi.Domain;
using Shogi.Domain.Aggregates;
using Shogi.Domain.ValueObjects;
using System.Data;
using System.Data.SqlClient;
using System.Text.Json;
@@ -16,22 +18,53 @@ public class SessionRepository : ISessionRepository
connectionString = configuration.GetConnectionString("ShogiDatabase");
}
public async Task CreateSession(ShogiBoard session, string player1)
public async Task CreateSession(Session session)
{
var initialBoardState = JsonSerializer.Serialize(session.BoardState);
var boardStateDto = new BoardStateDto
{
InCheck = session.BoardState.InCheck,
IsCheckmate = session.BoardState.IsCheckmate,
Player1Hand = session.BoardState.Player1Hand,
Player2Hand = session.BoardState.Player2Hand,
State = session.BoardState.State,
WhoseTurn = session.BoardState.WhoseTurn,
};
using var connection = new SqlConnection(connectionString);
await connection.ExecuteAsync(
"session.CreateSession",
new
{
InitialBoardStateDocument = initialBoardState,
Name = sessionName,
InitialBoardStateDocument = JsonSerializer.Serialize(boardStateDto),
Player1Name = player1,
},
commandType: CommandType.StoredProcedure);
}
public async Task<ShogiBoard?> ReadShogiBoard(string name)
{
using var connection = new SqlConnection(connectionString);
var results = await connection.QueryAsync<SessionDto>(
"session.ReadSession",
commandType: CommandType.StoredProcedure);
var dto = results.SingleOrDefault();
if (dto == null) return null;
var boardState = new BoardState(
state: new(dto.BoardState.State),
player1Hand: dto.BoardState.Player1Hand,
player2Hand: dto.BoardState.Player2Hand,
whoseTurn: dto.BoardState.WhoseTurn,
playerInCheck: dto.BoardState.InCheck,
previousMove: dto.BoardState.PreviousMove);
var session = new ShogiBoard(boardState);
return session;
}
}
public interface ISessionRepository
{
Task CreateSession(ShogiBoard session, string player1);
Task CreateSession(Session session);
Task<ShogiBoard?> ReadShogiBoard(string name);
}

View File

@@ -14,7 +14,7 @@ namespace Shogi.Domain.UnitTests
public void MoveAPieceToAnEmptyPosition()
{
// Arrange
var shogi = MockSession();
var shogi = MockShogiBoard();
var board = shogi.BoardState;
board["A4"].Should().BeNull();
@@ -33,7 +33,7 @@ namespace Shogi.Domain.UnitTests
public void AllowValidMoves_AfterCheck()
{
// Arrange
var shogi = MockSession();
var shogi = MockShogiBoard();
var board = shogi.BoardState;
// P1 Pawn
shogi.Move("C3", "C4", false);
@@ -58,7 +58,7 @@ namespace Shogi.Domain.UnitTests
public void PreventInvalidMoves_MoveFromEmptyPosition()
{
// Arrange
var shogi = MockSession();
var shogi = MockShogiBoard();
var board = shogi.BoardState;
board["D5"].Should().BeNull();
@@ -77,7 +77,7 @@ namespace Shogi.Domain.UnitTests
public void PreventInvalidMoves_MoveToCurrentPosition()
{
// Arrange
var shogi = MockSession();
var shogi = MockShogiBoard();
var board = shogi.BoardState;
var expectedPiece = board["A3"];
@@ -98,7 +98,7 @@ namespace Shogi.Domain.UnitTests
public void PreventInvalidMoves_MoveSet()
{
// Arrange
var shogi = MockSession();
var shogi = MockShogiBoard();
var board = shogi.BoardState;
var expectedPiece = board["A1"];
expectedPiece!.WhichPiece.Should().Be(WhichPiece.Lance);
@@ -121,7 +121,7 @@ namespace Shogi.Domain.UnitTests
public void PreventInvalidMoves_Ownership()
{
// Arrange
var shogi = MockSession();
var shogi = MockShogiBoard();
var board = shogi.BoardState;
var expectedPiece = board["A7"];
expectedPiece!.Owner.Should().Be(WhichPlayer.Player2);
@@ -143,7 +143,7 @@ namespace Shogi.Domain.UnitTests
public void PreventInvalidMoves_MoveThroughAllies()
{
// Arrange
var shogi = MockSession();
var shogi = MockShogiBoard();
var board = shogi.BoardState;
var lance = board["A1"];
var pawn = board["A3"];
@@ -166,7 +166,7 @@ namespace Shogi.Domain.UnitTests
public void PreventInvalidMoves_CaptureAlly()
{
// Arrange
var shogi = MockSession();
var shogi = MockShogiBoard();
var board = shogi.BoardState;
var knight = board["B1"];
var pawn = board["C3"];
@@ -190,7 +190,7 @@ namespace Shogi.Domain.UnitTests
public void PreventInvalidMoves_Check()
{
// Arrange
var shogi = MockSession();
var shogi = MockShogiBoard();
var board = shogi.BoardState;
// P1 Pawn
shogi.Move("C3", "C4", false);
@@ -219,7 +219,7 @@ namespace Shogi.Domain.UnitTests
public void PreventInvalidDrops_MoveSet()
{
// Arrange
var shogi = MockSession();
var shogi = MockShogiBoard();
var board = shogi.BoardState;
// P1 Pawn
shogi.Move("C3", "C4", false);
@@ -358,7 +358,7 @@ namespace Shogi.Domain.UnitTests
public void Check()
{
// Arrange
var shogi = MockSession();
var shogi = MockShogiBoard();
var board = shogi.BoardState;
// P1 Pawn
shogi.Move("C3", "C4", false);
@@ -376,7 +376,7 @@ namespace Shogi.Domain.UnitTests
public void Promote()
{
// Arrange
var shogi = MockSession();
var shogi = MockShogiBoard();
var board = shogi.BoardState;
// P1 Pawn
shogi.Move("C3", "C4", false);
@@ -401,7 +401,7 @@ namespace Shogi.Domain.UnitTests
public void Capture()
{
// Arrange
var shogi = MockSession();
var shogi = MockShogiBoard();
var board = shogi.BoardState;
var p1Bishop = board["B2"];
p1Bishop!.WhichPiece.Should().Be(WhichPiece.Bishop);
@@ -425,7 +425,7 @@ namespace Shogi.Domain.UnitTests
public void CheckMate()
{
// Arrange
var shogi = MockSession();
var shogi = MockShogiBoard();
var board = shogi.BoardState;
// P1 Rook
shogi.Move("H2", "E2", false);
@@ -457,6 +457,6 @@ namespace Shogi.Domain.UnitTests
board.InCheck.Should().Be(WhichPlayer.Player2);
}
private static ShogiBoard MockSession() => new ShogiBoard("Test Session", BoardState.StandardStarting, "Test P1", "Test P2");
private static ShogiBoard MockShogiBoard() => new ShogiBoard("Test Session", BoardState.StandardStarting);
}
}