diff --git a/Gameboard.ShogiUI.Sockets.sln b/Gameboard.ShogiUI.Sockets.sln index 3ddc148..1469cf3 100644 --- a/Gameboard.ShogiUI.Sockets.sln +++ b/Gameboard.ShogiUI.Sockets.sln @@ -17,6 +17,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PathFinding", "PathFinding\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Gameboard.ShogiUI.xUnitTests", "Gameboard.ShogiUI.xUnitTests\Gameboard.ShogiUI.xUnitTests.csproj", "{12530716-C11E-40CE-9F71-CCCC243F03E1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shogi.Domain", "Shogi.Domain\Shogi.Domain.csproj", "{0211B1E4-20F0-4058-AAC4-3845D19910AF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -47,6 +49,10 @@ Global {12530716-C11E-40CE-9F71-CCCC243F03E1}.Debug|Any CPU.Build.0 = Debug|Any CPU {12530716-C11E-40CE-9F71-CCCC243F03E1}.Release|Any CPU.ActiveCfg = Release|Any CPU {12530716-C11E-40CE-9F71-CCCC243F03E1}.Release|Any CPU.Build.0 = Release|Any CPU + {0211B1E4-20F0-4058-AAC4-3845D19910AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0211B1E4-20F0-4058-AAC4-3845D19910AF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0211B1E4-20F0-4058-AAC4-3845D19910AF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0211B1E4-20F0-4058-AAC4-3845D19910AF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/PathFinding/PathFinder2D.cs b/PathFinding/PathFinder2D.cs index aa3f3b4..6a04823 100644 --- a/PathFinding/PathFinder2D.cs +++ b/PathFinding/PathFinder2D.cs @@ -5,132 +5,137 @@ using System.Numerics; namespace PathFinding { - public class PathFinder2D where T : IPlanarElement - { - /// Guaranteed to be non-null. - /// - public delegate void Callback(T collider, Vector2 position); + public class PathFinder2D where T : IPlanarElement + { + /// Guaranteed to be non-null. + /// + public delegate void Callback(T collider, Vector2 position); - private readonly IPlanarCollection collection; - private readonly int width; - private readonly int height; + private readonly IPlanarCollection collection; + private readonly int width; + private readonly int height; - /// Horizontal size, in steps, of the pathable plane. - /// Vertical size, in steps, of the pathable plane. - public PathFinder2D(IPlanarCollection collection, int width, int height) - { - this.collection = collection; - this.width = width; - this.height = height; - } + /// Horizontal size, in steps, of the pathable plane. + /// Vertical size, in steps, of the pathable plane. + public PathFinder2D(IPlanarCollection collection, int width, int height) + { + this.collection = collection; + this.width = width; + this.height = height; + } - /// - /// Navigate the collection such that each "step" is always towards the destination, respecting the Paths available to the element at origin. - /// - /// The pathing element. - /// The starting location. - /// The destination. - /// Do cool stuff here. - /// True if the element reached the destination. - public bool PathTo(Vector2 origin, Vector2 destination, Callback? callback = null) - { - if (destination.X > width - 1 || destination.Y > height - 1 || destination.X < 0 || destination.Y < 0) - { - return false; - } - var element = collection[origin]; - if (element == null) return false; + /// + /// Navigate the collection such that each "step" is always towards the destination, respecting the Paths available to the element at origin. + /// + /// The pathing element. + /// The starting location. + /// The destination. + /// Do cool stuff here. + /// True if the element reached the destination. + public bool PathTo(Vector2 origin, Vector2 destination, Callback? callback = null) + { + if (destination.X > width - 1 || destination.Y > height - 1 || destination.X < 0 || destination.Y < 0) + { + return false; + } + var element = collection[origin]; + if (element == null) return false; - var path = FindDirectionTowardsDestination(element.MoveSet.GetMoves(), origin, destination); - if (!IsPathable(origin, destination, path.Direction)) - { - // Assumption: if a single best-choice step towards the destination cannot happen, no pathing can happen. - return false; - } + var path = FindDirectionTowardsDestination(element.MoveSet.GetMoves(), origin, destination); + if (!IsPathable(origin, destination, path.Direction)) + { + // Assumption: if a single best-choice step towards the destination cannot happen, no pathing can happen. + return false; + } - var shouldPath = true; - var next = origin; - while (shouldPath && next != destination) - { - next = Vector2.Add(next, path.Direction); - var collider = collection[next]; - if (collider != null) - { - callback?.Invoke(collider, next); - shouldPath = false; - } - else if (path.Distance == Distance.OneStep) - { - shouldPath = false; - } - } - return next == destination; - } + var shouldPath = true; + var next = origin; + while (shouldPath && next != destination) + { + next = Vector2.Add(next, path.Direction); + var collider = collection[next]; + if (collider != null) + { + callback?.Invoke(collider, next); + shouldPath = false; + } + else if (path.Distance == Distance.OneStep) + { + shouldPath = false; + } + } + return next == destination; + } - public void PathEvery(Vector2 from, Callback callback) - { - var element = collection[from]; - if (element == null) - { - return; - } - foreach (var path in element.MoveSet.GetMoves()) - { - var shouldPath = true; - var next = Vector2.Add(from, path.Direction); ; - while (shouldPath && next.X < width && next.Y < height && next.X >= 0 && next.Y >= 0) - { - var collider = collection[(int)next.Y, (int)next.X]; - if (collider != null) - { - callback(collider, next); - shouldPath = false; - } - if (path.Distance == Distance.OneStep) - { - shouldPath = false; - } - next = Vector2.Add(next, path.Direction); - } - } - } + public void PathEvery(Vector2 from, Callback callback) + { + var element = collection[from]; + if (element == null) + { + return; + } + foreach (var path in element.MoveSet.GetMoves()) + { + var shouldPath = true; + var next = Vector2.Add(from, path.Direction); ; + while (shouldPath && next.X < width && next.Y < height && next.X >= 0 && next.Y >= 0) + { + var collider = collection[(int)next.Y, (int)next.X]; + if (collider != null) + { + callback(collider, next); + shouldPath = false; + } + if (path.Distance == Distance.OneStep) + { + shouldPath = false; + } + next = Vector2.Add(next, path.Direction); + } + } + } - /// - /// Path the line from origin to destination, ignoring any Paths defined by the element at origin. - /// - public void LinePathTo(Vector2 origin, Vector2 direction, Callback callback) - { - direction = Vector2.Normalize(direction); + /// + /// Path the line from origin to destination, ignoring any Paths defined by the element at origin. + /// + public void LinePathTo(Vector2 origin, Vector2 direction, Callback callback) + { + direction = Vector2.Normalize(direction); - var next = Vector2.Add(origin, direction); - while (next.X >= 0 && next.X < width && next.Y >= 0 && next.Y < height) - { - var element = collection[next]; - if (element != null) callback(element, next); - next = Vector2.Add(next, direction); - } - } + var next = Vector2.Add(origin, direction); + while (next.X >= 0 && next.X < width && next.Y >= 0 && next.Y < height) + { + var element = collection[next]; + if (element != null) callback(element, next); + next = Vector2.Add(next, direction); + } + } - public static Move FindDirectionTowardsDestination(ICollection paths, Vector2 origin, Vector2 destination) => - paths.Aggregate((a, b) => Vector2.Distance(destination, Vector2.Add(origin, a.Direction)) < Vector2.Distance(destination, Vector2.Add(origin, b.Direction)) ? a : b); + public static Move FindDirectionTowardsDestination(ICollection paths, Vector2 origin, Vector2 destination) => + paths.Aggregate((a, b) => + { + var distanceA = Vector2.Distance(destination, Vector2.Add(origin, a.Direction)); + var distanceB = Vector2.Distance(destination, Vector2.Add(origin, b.Direction)); + return distanceA < distanceB ? a : b; + }); - public static bool IsPathable(Vector2 origin, Vector2 destination, Vector2 direction) - { - var next = Vector2.Add(origin, direction); - if (Vector2.Distance(next, destination) >= Vector2.Distance(origin, destination)) return false; + public static bool IsPathable(Vector2 origin, Vector2 destination, Vector2 direction) + { + var next = Vector2.Add(origin, direction); + if (Vector2.Distance(next, destination) >= Vector2.Distance(origin, destination)) return false; - var slope = (destination.Y - origin.Y) / (destination.X - origin.X); - if (float.IsInfinity(slope)) - { - return next.X == destination.X; - } - else - { - // b = -mx + y - var yIntercept = -slope * origin.X + origin.Y; - // y = mx + b - return next.Y == slope * next.X + yIntercept; - } - } - } + var slope = (destination.Y - origin.Y) / (destination.X - origin.X); + if (float.IsInfinity(slope)) + { + return next.X == destination.X; + } + else + { + // b = -mx + y + var yIntercept = -slope * origin.X + origin.Y; + // y = mx + b + return next.Y == slope * next.X + yIntercept; + } + } + } } diff --git a/Shogi.Domain/Move.cs b/Shogi.Domain/Move.cs new file mode 100644 index 0000000..30d19d5 --- /dev/null +++ b/Shogi.Domain/Move.cs @@ -0,0 +1,53 @@ +using System.Diagnostics; +using System.Numerics; + +namespace Shogi.Domain +{ + [DebuggerDisplay("{From} - {To}")] + public class Move + { + public Vector2? From { get; } // TODO: Use string notation + public bool IsPromotion { get; } + public WhichPiece? PieceFromHand { get; } + public Vector2 To { get; } + + public Move(Vector2 from, Vector2 to, bool isPromotion = false) + { + From = from; + To = to; + IsPromotion = isPromotion; + } + public Move(WhichPiece pieceFromHand, Vector2 to) + { + PieceFromHand = pieceFromHand; + To = to; + } + + /// + /// Constructor to represent moving a piece on the Board to another position on the Board. + /// + /// Position the piece is being moved from. + /// Position the piece is being moved to. + /// If the moving piece should be promoted. + public Move(string fromNotation, string toNotation, bool isPromotion = false) + { + //From = NotationHelper.FromBoardNotation(fromNotation); + //To = NotationHelper.FromBoardNotation(toNotation); + //IsPromotion = isPromotion; + } + + /// + /// Constructor to represent moving a piece from the Hand to the Board. + /// + /// The piece being moved from the Hand to the Board. + /// Position the piece is being moved to. + /// If the moving piece should be promoted. + public Move(WhichPiece pieceFromHand, string toNotation, bool isPromotion = false) + { + //From = null; + //PieceFromHand = pieceFromHand; + //To = NotationHelper.FromBoardNotation(toNotation); + //IsPromotion = isPromotion; + } + } +} diff --git a/Shogi.Domain/MoveResult.cs b/Shogi.Domain/MoveResult.cs new file mode 100644 index 0000000..fed07d6 --- /dev/null +++ b/Shogi.Domain/MoveResult.cs @@ -0,0 +1,14 @@ +namespace Shogi.Domain +{ + public class MoveResult + { + public bool Success { get; } + public string Reason { get; } + + public MoveResult(bool isSuccess, string reason = "") + { + Success = isSuccess; + Reason = reason; + } + } +} diff --git a/Shogi.Domain/Piece.cs b/Shogi.Domain/Piece.cs new file mode 100644 index 0000000..0ccc94f --- /dev/null +++ b/Shogi.Domain/Piece.cs @@ -0,0 +1,44 @@ +using System.Diagnostics; + +namespace Shogi.Domain +{ + [DebuggerDisplay("{WhichPiece} {Owner}")] + public class Piece + { + public WhichPiece WhichPiece { get; } + public WhichPlayer Owner { get; private set; } + public bool IsPromoted { get; private set; } + public bool IsUpsideDown => Owner == WhichPlayer.Player2; + + public Piece(WhichPiece piece, WhichPlayer owner, bool isPromoted = false) + { + WhichPiece = piece; + Owner = owner; + IsPromoted = isPromoted; + } + public Piece(Piece piece) : this(piece.WhichPiece, piece.Owner, piece.IsPromoted) + { + } + + public bool CanPromote => !IsPromoted + && WhichPiece != WhichPiece.King + && WhichPiece != WhichPiece.GoldGeneral; + + public void ToggleOwnership() + { + Owner = Owner == WhichPlayer.Player1 + ? WhichPlayer.Player2 + : WhichPlayer.Player1; + } + + public void Promote() => IsPromoted = CanPromote; + + public void Demote() => IsPromoted = false; + + public void Capture() + { + ToggleOwnership(); + Demote(); + } + } +} diff --git a/Shogi.Domain/Shogi.Domain.csproj b/Shogi.Domain/Shogi.Domain.csproj new file mode 100644 index 0000000..132c02c --- /dev/null +++ b/Shogi.Domain/Shogi.Domain.csproj @@ -0,0 +1,9 @@ + + + + net6.0 + enable + enable + + + diff --git a/Shogi.Domain/Shogi.cs b/Shogi.Domain/Shogi.cs new file mode 100644 index 0000000..c7cbad6 --- /dev/null +++ b/Shogi.Domain/Shogi.cs @@ -0,0 +1,90 @@ +using System.Numerics; + +namespace Shogi.Domain +{ + /// + /// Facilitates Shogi board state transitions, cognisant of Shogi rules. + /// The board is always from Player1's perspective. + /// [0,0] is the lower-left position, [8,8] is the higher-right position + /// + public sealed class Shogi + { + private readonly ShogiBoardState board; + private readonly StandardRules rules; + public string Error { get; private set; } + + public Shogi(ShogiBoardState board) + { + this.board = board; + rules = new StandardRules(this.board); + Error = string.Empty; + } + + public Shogi(IList moves) : this() + { + for (var i = 0; i < moves.Count; i++) + { + if (!Move(moves[i])) + { + // Todo: Add some smarts to know why a move was invalid. In check? Piece not found? etc. + throw new InvalidOperationException($"Unable to construct ShogiBoard with the given move at index {i}. {Error}"); + } + } + } + + public bool Move(Move move) + { + var moveSuccess = TryMove(move); + + if (!moveSuccess) + { + return false; + } + + var otherPlayer = WhoseTurn == WhichPlayer.Player1 ? WhichPlayer.Player2 : WhichPlayer.Player1; + if (EvaluateCheckAfterMove(move, otherPlayer)) + { + InCheck = otherPlayer; + IsCheckmate = EvaluateCheckmate(); + } + else + { + InCheck = null; + } + return true; + } + + /// + /// Attempts a given move. Returns false if the move is illegal. + /// + private bool TryMove(Move move) + { + // Try making the move in a "throw away" board. + var simulator = new StandardRules(new ShogiBoardState(this.board)); + + var simulatedMoveResults = move.PieceFromHand.HasValue + ? simulator.PlaceFromHand(move) + : simulator.PlaceFromBoard(move); + if (!simulatedMoveResults) + { + // Surface the error description. + Error = simulationBoard.Error; + return false; + } + // If already in check, assert the move that resulted in check no longer results in check. + if (InCheck == WhoseTurn) + { + if (simulationBoard.EvaluateCheckAfterMove(MoveHistory[^1], WhoseTurn)) + { + // Sneakily using this.WhoseTurn instead of validationBoard.WhoseTurn; + return false; + } + } + + // The move is valid and legal; update board state. + if (move.PieceFromHand.HasValue) PlaceFromHand(move); + else PlaceFromBoard(move); + return true; + } + } +} diff --git a/Shogi.Domain/ShogiBoardState.cs b/Shogi.Domain/ShogiBoardState.cs new file mode 100644 index 0000000..83e70ae --- /dev/null +++ b/Shogi.Domain/ShogiBoardState.cs @@ -0,0 +1,199 @@ +using System.Numerics; +using System.Text.RegularExpressions; + +namespace Shogi.Domain +{ + // TODO: Avoid extending dictionary. Use composition instead. + // Then validation can occur when assigning a piece to a position. + public class ShogiBoardState + { + private static readonly string BoardNotationRegex = @"(?[a-iA-I])(?[1-9])"; + private static readonly char A = 'A'; + public delegate void ForEachDelegate(Piece element, Vector2 position); + /// + /// Key is position notation, such as "E4". + /// + private Dictionary board; + + public List Hand => WhoseTurn == WhichPlayer.Player1 ? Player1Hand : Player2Hand; + public List Player1Hand { get; } + public List Player2Hand { get; } + public List MoveHistory { get; } + public WhichPlayer WhoseTurn => MoveHistory.Count % 2 == 0 ? WhichPlayer.Player1 : WhichPlayer.Player2; + public WhichPlayer? InCheck { get; private set; } + public bool IsCheckmate { get; private set; } + + public ShogiBoardState() + { + board = new Dictionary(81); + InitializeBoardState(); + Player1Hand = new List(); + Player2Hand = new List(); + MoveHistory = new List(); + } + + + /// + /// Copy constructor. + /// + public ShogiBoardState(ShogiBoardState other) : this() + { + foreach (var kvp in other.board) + { + board[kvp.Key] = kvp.Value == null ? null : new Piece(kvp.Value); + } + MoveHistory.AddRange(other.MoveHistory); + Player1Hand.AddRange(other.Player1Hand); + Player2Hand.AddRange(other.Player2Hand); + } + + public Piece? this[string notation] + { + // TODO: Validate "notation" here and throw an exception if invalid. + get => board[notation.ToUpper()]; + set => board[notation.ToUpper()] = value; + } + + public Piece? this[Vector2 vector] + { + get => this[ToBoardNotation(vector)]; + set => this[ToBoardNotation(vector)] = value; + } + + public Piece? this[int x, int y] + { + get => this[ToBoardNotation(x, y)]; + set => this[ToBoardNotation(x, y)] = value; + } + + public void ForEachNotNull(ForEachDelegate callback) + { + for (var x = 0; x < 9; x++) + { + for (var y = 0; y < 9; y++) + { + var position = new Vector2(x, y); + var elem = this[position]; + if (elem != null) + callback(elem, position); + } + } + } + + public static string ToBoardNotation(Vector2 vector) + { + return ToBoardNotation((int)vector.X, (int)vector.Y); + } + public static string ToBoardNotation(int x, int y) + { + var file = (char)(x + A); + var rank = y + 1; + return $"{file}{rank}"; + } + public static Vector2 FromBoardNotation(string notation) + { + notation = notation.ToUpper(); + if (Regex.IsMatch(notation, BoardNotationRegex)) + { + var match = Regex.Match(notation, BoardNotationRegex); + char file = match.Groups["file"].Value[0]; + int rank = int.Parse(match.Groups["rank"].Value); + return new Vector2(file - A, rank - 1); + } + throw new ArgumentException($"Board notation not recognized. Notation given: {notation}"); + } + + private void InitializeBoardState() + { + this["A1"] = new Piece(WhichPiece.Lance, WhichPlayer.Player1); + this["B1"] = new Piece(WhichPiece.Knight, WhichPlayer.Player1); + this["C1"] = new Piece(WhichPiece.SilverGeneral, WhichPlayer.Player1); + this["D1"] = new Piece(WhichPiece.GoldGeneral, WhichPlayer.Player1); + this["E1"] = new Piece(WhichPiece.King, WhichPlayer.Player1); + this["F1"] = new Piece(WhichPiece.GoldGeneral, WhichPlayer.Player1); + this["G1"] = new Piece(WhichPiece.SilverGeneral, WhichPlayer.Player1); + this["H1"] = new Piece(WhichPiece.Knight, WhichPlayer.Player1); + this["I1"] = new Piece(WhichPiece.Lance, WhichPlayer.Player1); + + this["A2"] = null; + this["B2"] = new Piece(WhichPiece.Bishop, WhichPlayer.Player1); + this["C2"] = null; + this["D2"] = null; + this["E2"] = null; + this["F2"] = null; + this["G2"] = null; + this["H2"] = new Piece(WhichPiece.Rook, WhichPlayer.Player1); + this["I2"] = null; + + this["A3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + this["B3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + this["C3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + this["D3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + this["E3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + this["F3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + this["G3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + this["H3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + this["I3"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player1); + + this["A4"] = null; + this["B4"] = null; + this["C4"] = null; + this["D4"] = null; + this["E4"] = null; + this["F4"] = null; + this["G4"] = null; + this["H4"] = null; + this["I4"] = null; + + this["A5"] = null; + this["B5"] = null; + this["C5"] = null; + this["D5"] = null; + this["E5"] = null; + this["F5"] = null; + this["G5"] = null; + this["H5"] = null; + this["I5"] = null; + + this["A6"] = null; + this["B6"] = null; + this["C6"] = null; + this["D6"] = null; + this["E6"] = null; + this["F6"] = null; + this["G6"] = null; + this["H6"] = null; + this["I6"] = null; + + this["A7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + this["B7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + this["C7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + this["D7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + this["E7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + this["F7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + this["G7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + this["H7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + this["I7"] = new Piece(WhichPiece.Pawn, WhichPlayer.Player2); + + this["A8"] = null; + this["B8"] = new Piece(WhichPiece.Rook, WhichPlayer.Player2); + this["C8"] = null; + this["D8"] = null; + this["E8"] = null; + this["F8"] = null; + this["G8"] = null; + this["H8"] = new Piece(WhichPiece.Bishop, WhichPlayer.Player2); + this["I8"] = null; + + this["A9"] = new Piece(WhichPiece.Lance, WhichPlayer.Player2); + this["B9"] = new Piece(WhichPiece.Knight, WhichPlayer.Player2); + this["C9"] = new Piece(WhichPiece.SilverGeneral, WhichPlayer.Player2); + this["D9"] = new Piece(WhichPiece.GoldGeneral, WhichPlayer.Player2); + this["E9"] = new Piece(WhichPiece.King, WhichPlayer.Player2); + this["F9"] = new Piece(WhichPiece.GoldGeneral, WhichPlayer.Player2); + this["G9"] = new Piece(WhichPiece.SilverGeneral, WhichPlayer.Player2); + this["H9"] = new Piece(WhichPiece.Knight, WhichPlayer.Player2); + this["I9"] = new Piece(WhichPiece.Lance, WhichPlayer.Player2); + } + } +} diff --git a/Shogi.Domain/StandardRules.cs b/Shogi.Domain/StandardRules.cs new file mode 100644 index 0000000..0f04c50 --- /dev/null +++ b/Shogi.Domain/StandardRules.cs @@ -0,0 +1,268 @@ +using System.Numerics; + +namespace Shogi.Domain +{ + internal class StandardRules + { + private readonly ShogiBoardState board; + private Vector2 player1KingPosition; + private Vector2 player2KingPosition; + + public StandardRules(ShogiBoardState board) + { + this.board = board; + CacheKingPositions(); + } + + private void CacheKingPositions() + { + this.board.ForEachNotNull((tile, position) => + { + if (tile.WhichPiece == WhichPiece.King) + { + if (tile.Owner == WhichPlayer.Player1) + { + player1KingPosition = position; + } + else if (tile.Owner == WhichPlayer.Player2) + { + player2KingPosition = position; + } + } + }); + } + + /// + /// Move a piece from a board tile to another board tile. + /// + /// The position of the piece being moved expressed in board notation. + /// The target position expressed in board notation. + /// A describing the success or failure of the simulation. + public MoveResult Move(string from, string to, bool isPromotion = false) + { + var fromPiece = board[from]; + if (fromPiece == null) + { + return new MoveResult(false, $"Tile [{from}] is empty. There is no piece to move."); + } + if (fromPiece.Owner != board.WhoseTurn) + { + return new MoveResult(false, "Not allowed to move the opponents piece"); + } + if (IsPathable(move.From.Value, move.To) == false) + { + return new MoveResult(false, $"Proposed move is not part of the move-set for piece {fromPiece.WhichPiece}."); + } + + var captured = board[to]; + if (captured != null) + { + if (captured.Owner == board.WhoseTurn) + { + return new MoveResult(false, "Capturing your own piece is not allowed."); + } + captured.Capture(); + board.Hand.Add(captured); + } + + //Mutate the board. + if (isPromotion) + { + var fromVector = ShogiBoardState.FromBoardNotation(from); + var toVector = ShogiBoardState.FromBoardNotation(to); + if (board.WhoseTurn == WhichPlayer.Player1 && (toVector.Y > 5 || fromVector.Y > 5)) + { + fromPiece.Promote(); + } + else if (board.WhoseTurn == WhichPlayer.Player2 && (toVector.Y < 3 || fromVector.Y < 3)) + { + fromPiece.Promote(); + } + } + board[to] = fromPiece; + board[from] = null; + if (fromPiece.WhichPiece == WhichPiece.King) + { + if (fromPiece.Owner == WhichPlayer.Player1) + { + player1King.X = move.To.X; + player1King.Y = move.To.Y; + } + else if (fromPiece.Owner == WhichPlayer.Player2) + { + player2King.X = move.To.X; + player2King.Y = move.To.Y; + } + } + MoveHistory.Add(move); + return true; + } + + /// + /// Move a piece from the hand to the board. + /// + /// + /// The target position expressed in board notation. + /// A describing the success or failure of the simulation. + public void Move(WhichPiece pieceInHand, string to) + { + var index = Hand.FindIndex(p => p.WhichPiece == move.PieceFromHand); + if (index == -1) + { + Error = $"{move.PieceFromHand} does not exist in the hand."; + return false; + } + if (Board[move.To] != null) + { + Error = $"Illegal move - attempting to capture while playing a piece from the hand."; + return false; + } + + switch (move.PieceFromHand!.Value) + { + case WhichPiece.Knight: + { + // Knight cannot be placed onto the farthest two ranks from the hand. + if ((WhoseTurn == WhichPlayer.Player1 && move.To.Y > 6) + || (WhoseTurn == WhichPlayer.Player2 && move.To.Y < 2)) + { + Error = $"Knight has no valid moves after placed."; + return false; + } + break; + } + case WhichPiece.Lance: + case WhichPiece.Pawn: + { + // Lance and Pawn cannot be placed onto the farthest rank from the hand. + if ((WhoseTurn == WhichPlayer.Player1 && move.To.Y == 8) + || (WhoseTurn == WhichPlayer.Player2 && move.To.Y == 0)) + { + Error = $"{move.PieceFromHand} has no valid moves after placed."; + return false; + } + break; + } + } + + // Mutate the board. + Board[move.To] = Hand[index]; + Hand.RemoveAt(index); + MoveHistory.Add(move); + + return true; + } + + private bool IsPathable(Vector2 from, Vector2 to) + { + var piece = Board[from]; + if (piece == null) return false; + + var isObstructed = false; + var isPathable = pathFinder.PathTo(from, to, (other, position) => + { + if (other.Owner == piece.Owner) isObstructed = true; + }); + return !isObstructed && isPathable; + } + + private bool EvaluateCheckAfterMove(Move move, WhichPlayer WhichPerspective) + { + if (WhichPerspective == InCheck) return true; // If we already know the player is in check, don't bother. + + var isCheck = false; + var kingPosition = WhichPerspective == WhichPlayer.Player1 ? player1King : player2King; + + // Check if the move put the king in check. + if (pathFinder.PathTo(move.To, kingPosition)) return true; + + if (move.From.HasValue) + { + // Get line equation from king through the now-unoccupied location. + var direction = Vector2.Subtract(kingPosition, move.From!.Value); + var slope = Math.Abs(direction.Y / direction.X); + // If absolute slope is 45°, look for a bishop along the line. + // If absolute slope is 0° or 90°, look for a rook along the line. + // if absolute slope is 0°, look for lance along the line. + if (float.IsInfinity(slope)) + { + // if slope of the move is also infinity...can skip this? + pathFinder.LinePathTo(kingPosition, direction, (piece, position) => + { + if (piece.Owner != WhichPerspective) + { + switch (piece.WhichPiece) + { + case WhichPiece.Rook: + isCheck = true; + break; + case WhichPiece.Lance: + if (!piece.IsPromoted) isCheck = true; + break; + } + } + }); + } + else if (slope == 1) + { + pathFinder.LinePathTo(kingPosition, direction, (piece, position) => + { + if (piece.Owner != WhichPerspective && piece.WhichPiece == WhichPiece.Bishop) + { + isCheck = true; + } + }); + } + else if (slope == 0) + { + pathFinder.LinePathTo(kingPosition, direction, (piece, position) => + { + if (piece.Owner != WhichPerspective && piece.WhichPiece == WhichPiece.Rook) + { + isCheck = true; + } + }); + } + } + else + { + // TODO: Check for illegal move from hand. It is illegal to place from the hand such that you check-mate your opponent. + // Go read the shogi rules to be sure this is true. + } + + return isCheck; + } + + private bool EvaluateCheckmate() + { + if (!InCheck.HasValue) return false; + + // Assume true and try to disprove. + var isCheckmate = true; + Board.ForEachNotNull((piece, from) => // For each piece... + { + // Short circuit + if (!isCheckmate) return; + + if (piece.Owner == InCheck) // ...owned by the player in check... + { + // ...evaluate if any move gets the player out of check. + pathFinder.PathEvery(from, (other, position) => + { + var simulationBoard = new Shogi(this); + var moveToTry = new Move(from, position); + var moveSuccess = simulationBoard.TryMove(moveToTry); + if (moveSuccess) + { + if (!EvaluateCheckAfterMove(moveToTry, InCheck.Value)) + { + isCheckmate = false; + } + } + }); + } + }); + return isCheckmate; + } + } +} diff --git a/Shogi.Domain/WhichPiece.cs b/Shogi.Domain/WhichPiece.cs new file mode 100644 index 0000000..58a1669 --- /dev/null +++ b/Shogi.Domain/WhichPiece.cs @@ -0,0 +1,14 @@ +namespace Shogi.Domain +{ + public enum WhichPiece + { + King, + GoldGeneral, + SilverGeneral, + Bishop, + Rook, + Knight, + Lance, + Pawn + } +} diff --git a/Shogi.Domain/WhichPlayer.cs b/Shogi.Domain/WhichPlayer.cs new file mode 100644 index 0000000..584d852 --- /dev/null +++ b/Shogi.Domain/WhichPlayer.cs @@ -0,0 +1,8 @@ +namespace Shogi.Domain +{ + public enum WhichPlayer + { + Player1, + Player2 + } +}