From f75553a0ad9a14a9e6031c9bccc857e0502c3f8f Mon Sep 17 00:00:00 2001 From: Lucas Morgan Date: Fri, 11 Oct 2024 11:10:38 -0500 Subject: [PATCH] yep --- Shogi.Domain/Other/BoardRules.cs | 81 ++++ Shogi.Domain/Other/IRulesLifecycle.cs | 15 + Shogi.Domain/Other/MoveValidationContext.cs | 14 + Shogi.Domain/Other/PieceRulesRegistration.cs | 11 - Shogi.Domain/Other/Rules.cs | 61 --- Shogi.Domain/Other/RulesLifecycleResult.cs | 5 + Shogi.Domain/ValueObjects/BoardState.cs | 93 +++- Shogi.Domain/ValueObjects/GoldGeneral.cs | 45 +- Shogi.Domain/ValueObjects/King.cs | 43 +- Shogi.Domain/ValueObjects/Piece.cs | 187 +++++--- Shogi.Domain/ValueObjects/ShogiBoard.cs | 436 +++++++++--------- Shogi.Domain/ValueObjects/StandardRules.cs | 107 +---- .../Pathing/Distance.cs | 17 +- .../YetToBeAssimilatedIntoDDD/Pathing/Path.cs | 15 +- 14 files changed, 616 insertions(+), 514 deletions(-) create mode 100644 Shogi.Domain/Other/BoardRules.cs create mode 100644 Shogi.Domain/Other/IRulesLifecycle.cs create mode 100644 Shogi.Domain/Other/MoveValidationContext.cs delete mode 100644 Shogi.Domain/Other/PieceRulesRegistration.cs delete mode 100644 Shogi.Domain/Other/Rules.cs create mode 100644 Shogi.Domain/Other/RulesLifecycleResult.cs diff --git a/Shogi.Domain/Other/BoardRules.cs b/Shogi.Domain/Other/BoardRules.cs new file mode 100644 index 0000000..8639730 --- /dev/null +++ b/Shogi.Domain/Other/BoardRules.cs @@ -0,0 +1,81 @@ + +namespace Shogi.Domain.Other; + +/// +/// +/// +/// A 2D array of pieces, representing your board. Indexed as [x, y]. +public class BoardRules(TPiece?[,] boardState) where TPiece : IRulesLifecycle +{ + private readonly Vector2 MaxIndex = new(boardState.GetLength(0) - 1, boardState.GetLength(1) - 1); + + /// + /// Validates a move, invoking the callback which you should implement. + /// A move is considered valid if it could be made legally, ignoring check or check-mate rules. + /// Check and check-mate verifying happens in a different lifecycle callback. + /// + /// The position of the piece being moved. + /// The desired destination of the piece being moved. + /// TODO + /// + public RulesLifecycleResult ValidateMove(Vector2 from, Vector2 to, bool isPromotion) + { + if (IsWithinBounds(from) && IsWithinBounds(to)) + { + var piece = boardState[(int)from.X, (int)from.Y]; + if (piece == null) + { + return new RulesLifecycleResult(IsError: true, $"There is no piece at position {from}."); + } + + return piece.OnMoveValidation(new MoveValidationContext(from, to, isPromotion, boardState)); + } + + return new RulesLifecycleResult(IsError: true, "test message"); + } + + + //foreach (var piece in boardState) + //{ + // if (piece != null) + // { + // var result = piece.OnMoveValidation(new MoveValidationContext(from, to, isPromotion, boardState)); + // if (result.IsError) + // { + // return result; + // } + // } + //} + + //public int ValidateMove(Vector2 start, Vector2 end) + //{ + // if (board.GetLength(0) != boardSize.X || board.GetLength(1) != boardSize.Y) + // { + // throw new ArgumentException($"2D array dimensions must match boardSize given during {nameof(CreateNewRules)} method.", nameof(board)); + // } + // if (start - start != Vector2.Zero) + // { + // throw new ArgumentException("Negative values not allowed.", nameof(start)); + // } + // if (end - end != Vector2.Zero) + // { + // throw new ArgumentException("Negative values not allowed.", nameof(end)); + // } + // if (start.X >= boardSize.X || start.Y >= boardSize.Y) + // { + // throw new ArgumentException("Start position must be within the given boardSize.", nameof(start)); + // } + // if (end.X >= boardSize.X || end.Y >= boardSize.Y) + // { + // throw new ArgumentException("End position must be within the given boardSize.", nameof(end)); + // } + + // return 0; + //} + + private bool IsWithinBounds(Vector2 position) + { + var isPositive = position - position == Vector2.Zero; + return isPositive && position.X <= MaxIndex.X && position.Y <= MaxIndex.Y; + } +} \ No newline at end of file diff --git a/Shogi.Domain/Other/IRulesLifecycle.cs b/Shogi.Domain/Other/IRulesLifecycle.cs new file mode 100644 index 0000000..4255bcd --- /dev/null +++ b/Shogi.Domain/Other/IRulesLifecycle.cs @@ -0,0 +1,15 @@ +namespace Shogi.Domain.Other; + +public interface IRulesLifecycle where TPiece : IRulesLifecycle +{ + /// + /// Invoked by during the MoveValidation life cycle event. + /// If a move begins or ends outside the board space coordinates, this function is not called. + /// The purpose is to ensure a proposed board move is valid with regard to the moved piece's rules. + /// This event does not worry about check or check-mate, or if a move is legal. + /// + /// + /// A context object with information for you to use to assess whether a move is valid for your implementing piece. + /// A new object indicating whether or not the move is valid. + RulesLifecycleResult OnMoveValidation(MoveValidationContext context); +} diff --git a/Shogi.Domain/Other/MoveValidationContext.cs b/Shogi.Domain/Other/MoveValidationContext.cs new file mode 100644 index 0000000..32b4206 --- /dev/null +++ b/Shogi.Domain/Other/MoveValidationContext.cs @@ -0,0 +1,14 @@ +namespace Shogi.Domain.Other; + +public record MoveValidationContext( + Vector2 From, + Vector2 To, + bool IsPromotion, + TPiece?[,] BoardState) where TPiece : IRulesLifecycle +{ + public TPiece? GetPieceByRelativePosition(Vector2 relativePosition) + { + var absolute = From + relativePosition; + return BoardState[(int)absolute.X, (int)absolute.Y]; + } +} diff --git a/Shogi.Domain/Other/PieceRulesRegistration.cs b/Shogi.Domain/Other/PieceRulesRegistration.cs deleted file mode 100644 index 9661745..0000000 --- a/Shogi.Domain/Other/PieceRulesRegistration.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing; - -namespace Shogi.Domain.Other; - -public record PieceRulesRegistration(TPiece WhichPiece, ICollection MoveSet) where TPiece : Enum -{ -} - -public record PieceInPlay(TPiece WhichPiece, int OwningPlayerNumber) where TPiece : Enum -{ -} diff --git a/Shogi.Domain/Other/Rules.cs b/Shogi.Domain/Other/Rules.cs deleted file mode 100644 index d2b3c89..0000000 --- a/Shogi.Domain/Other/Rules.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing; -using System.Drawing; - -namespace Shogi.Domain.Other; - -public class Rules where TPiece : Enum -{ - private Vector2 boardSize; - private TPiece theKing; - private Dictionary> piecePaths; - - /// - /// Begin a new set of rules. If any rules already exist, this method will erase them. - /// - /// The size of the game board in tiles. For examples, Chess is 8x8 and Shogi is 9x9. - /// The piece that represents the King for each player or the piece that, when lost, results in losing the game. - public Rules CreateNewRules(Vector2 boardSize, TPiece king) - { - this.boardSize = boardSize; - theKing = king; - piecePaths = []; - return this; - } - - public Rules RegisterPieceWithRules(PieceRulesRegistration pieceToRegister) - { - if (piecePaths.ContainsKey(pieceToRegister.WhichPiece)) - { - throw new ArgumentException("This type of piece has already been registered.", nameof(pieceToRegister)); - } - - piecePaths.Add(pieceToRegister.WhichPiece, pieceToRegister.MoveSet); - return this; - } - - public int ValidateMove(Vector2 start, Vector2 end, TPiece[][] board) - { - if (board.GetLength(0) != boardSize.X || board.GetLength(1) != boardSize.Y) - { - throw new ArgumentException($"2D array dimensions must match boardSize given during {nameof(CreateNewRules)} method.", nameof(board)); - } - if (start - start != Vector2.Zero) - { - throw new ArgumentException("Negative values not allowed.", nameof(start)); - } - if (end - end != Vector2.Zero) - { - throw new ArgumentException("Negative values not allowed.", nameof(end)); - } - if (start.X >= boardSize.X || start.Y >= boardSize.Y) - { - throw new ArgumentException("Start position must be within the given boardSize.", nameof(start)); - } - if (end.X >= boardSize.X || end.Y >= boardSize.Y) - { - throw new ArgumentException("End position must be within the given boardSize.", nameof(end)); - } - - - } -} \ No newline at end of file diff --git a/Shogi.Domain/Other/RulesLifecycleResult.cs b/Shogi.Domain/Other/RulesLifecycleResult.cs new file mode 100644 index 0000000..5dd255c --- /dev/null +++ b/Shogi.Domain/Other/RulesLifecycleResult.cs @@ -0,0 +1,5 @@ +namespace Shogi.Domain.Other; + +public record RulesLifecycleResult(bool IsError, string ResultMessage = "") +{ +} diff --git a/Shogi.Domain/ValueObjects/BoardState.cs b/Shogi.Domain/ValueObjects/BoardState.cs index a8c3dbb..5581ff5 100644 --- a/Shogi.Domain/ValueObjects/BoardState.cs +++ b/Shogi.Domain/ValueObjects/BoardState.cs @@ -95,6 +95,92 @@ public class BoardState set => this[Notation.ToBoardNotation(x, y)] = value; } + /// + /// Move a piece from a board tile to another board tile ignorant of check or check-mate. + /// If a piece is captured during the move, state will change to reflect that. + /// If a piece should promote during the move, state will change to reflect that. + /// + /// The position of the piece being moved expressed in board notation. + /// The target position expressed in board notation. + /// True if a promotion is expected as a result of this move. + /// A describing the success or failure of the move. + internal MoveResult Move(string fromNotation, string toNotation, bool isPromotionRequested = false) + { + var from = Notation.FromBoardNotation(fromNotation); + var to = Notation.FromBoardNotation(toNotation); + if (this[toNotation] != null) + { + Capture(to); + } + + var fromPiece = this[fromNotation] + ?? throw new InvalidOperationException($"No piece exists at position {fromNotation}."); + if (isPromotionRequested && + (IsWithinPromotionZone(to) || IsWithinPromotionZone(from))) + { + fromPiece.Promote(); + } + + this[toNotation] = fromPiece; + this[fromNotation] = null; + + PreviousMove = new Move(from, to); + var otherPlayer = WhoseTurn == WhichPlayer.Player1 ? WhichPlayer.Player2 : WhichPlayer.Player1; + WhoseTurn = otherPlayer; + + return new MoveResult(true); + } + + /// Move a piece from the hand to the board ignorant of check or check-mate. + /// + /// + /// The target position expressed in board notation. + /// A describing the success or failure of the simulation. + internal MoveResult Move(WhichPiece pieceInHand, string toNotation) + { + var index = ActivePlayerHand.FindIndex(p => p.WhichPiece == pieceInHand); + if (index == -1) + { + return new MoveResult(false, $"{pieceInHand} does not exist in the hand."); + } + if (this[toNotation] != null) + { + return new MoveResult(false, "Illegal move - attempting to capture while playing a piece from the hand."); + } + + var to = Notation.FromBoardNotation(toNotation); + switch (pieceInHand) + { + case WhichPiece.Knight: + { + // Knight cannot be placed onto the farthest two ranks from the hand. + if (WhoseTurn == WhichPlayer.Player1 && to.Y > 6 + || WhoseTurn == WhichPlayer.Player2 && to.Y < 2) + { + return new MoveResult(false, "Knight has no valid moves after placed."); + } + break; + } + case WhichPiece.Lance: + case WhichPiece.Pawn: + { + // Lance and Pawn cannot be placed onto the farthest rank from the hand. + if (WhoseTurn == WhichPlayer.Player1 && to.Y == 8 + || WhoseTurn == WhichPlayer.Player2 && to.Y == 0) + { + return new MoveResult(false, $"{pieceInHand} has no valid moves after placed."); + } + break; + } + } + + // Mutate the board. + this[toNotation] = ActivePlayerHand[index]; + ActivePlayerHand.RemoveAt(index); + PreviousMove = new Move(pieceInHand, to); + return new MoveResult(true); + } + /// /// Returns true if the given path can be traversed without colliding into a piece. /// @@ -107,8 +193,6 @@ public class BoardState 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; @@ -127,9 +211,8 @@ public class BoardState internal void Capture(Vector2 to) { - var piece = this[to]; - if (piece == null) throw new InvalidOperationException("Cannot capture. Piece at position does not exist."); - + var piece = this[to] + ?? throw new InvalidOperationException("Cannot capture. Piece at position does not exist."); piece.Capture(WhoseTurn); ActivePlayerHand.Add(piece); } diff --git a/Shogi.Domain/ValueObjects/GoldGeneral.cs b/Shogi.Domain/ValueObjects/GoldGeneral.cs index f54aa16..b1d0c26 100644 --- a/Shogi.Domain/ValueObjects/GoldGeneral.cs +++ b/Shogi.Domain/ValueObjects/GoldGeneral.cs @@ -1,31 +1,30 @@ using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing; using System.Collections.ObjectModel; -namespace Shogi.Domain.ValueObjects +namespace Shogi.Domain.ValueObjects; + +internal record class GoldGeneral : Piece { - internal record class GoldGeneral : Piece - { - public static readonly ReadOnlyCollection Player1Paths = new(new List(6) - { - new Path(Direction.Forward), - new Path(Direction.ForwardLeft), - new Path(Direction.ForwardRight), - new Path(Direction.Left), - new Path(Direction.Right), - new Path(Direction.Backward) - }); + private static readonly ReadOnlyCollection Player1Paths = new(new List(6) + { + new Path(Direction.Forward), + new Path(Direction.ForwardLeft), + new Path(Direction.ForwardRight), + new Path(Direction.Left), + new Path(Direction.Right), + new Path(Direction.Backward) + }); - public static readonly ReadOnlyCollection Player2Paths = - Player1Paths - .Select(p => p.Invert()) - .ToList() - .AsReadOnly(); + private static readonly ReadOnlyCollection Player2Paths = + Player1Paths + .Select(p => p.Invert()) + .ToList() + .AsReadOnly(); - public GoldGeneral(WhichPlayer owner, bool isPromoted = false) - : base(WhichPiece.GoldGeneral, owner, isPromoted) - { - } + public GoldGeneral(WhichPlayer owner, bool isPromoted = false) + : base(WhichPiece.GoldGeneral, owner, isPromoted) + { + } - public override IEnumerable MoveSet => Owner == WhichPlayer.Player1 ? Player1Paths : Player2Paths; - } + public override IEnumerable MoveSet => Owner == WhichPlayer.Player1 ? Player1Paths : Player2Paths; } diff --git a/Shogi.Domain/ValueObjects/King.cs b/Shogi.Domain/ValueObjects/King.cs index 5238e29..d7cf66c 100644 --- a/Shogi.Domain/ValueObjects/King.cs +++ b/Shogi.Domain/ValueObjects/King.cs @@ -1,27 +1,30 @@ using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing; using System.Collections.ObjectModel; -namespace Shogi.Domain.ValueObjects +namespace Shogi.Domain.ValueObjects; + +internal record class King : Piece { - internal record class King : Piece - { - internal static readonly ReadOnlyCollection KingPaths = new(new List(8) - { - new Path(Direction.Forward), - new Path(Direction.Left), - new Path(Direction.Right), - new Path(Direction.Backward), - new Path(Direction.ForwardLeft), - new Path(Direction.ForwardRight), - new Path(Direction.BackwardLeft), - new Path(Direction.BackwardRight) - }); + private static readonly ReadOnlyCollection Player1Paths = new( + [ + new Path(Direction.Forward), + new Path(Direction.Left), + new Path(Direction.Right), + new Path(Direction.Backward), + new Path(Direction.ForwardLeft), + new Path(Direction.ForwardRight), + new Path(Direction.BackwardLeft), + new Path(Direction.BackwardRight) + ]); + + private static readonly ReadOnlyCollection Player2Paths = Player1Paths.Select(p => p.Invert()).ToList().AsReadOnly(); + + public King(WhichPlayer owner, bool isPromoted = false) + : base(WhichPiece.King, owner, isPromoted) + { + } + + public override IEnumerable MoveSet => Owner == WhichPlayer.Player1 ? Player1Paths : Player2Paths; - public King(WhichPlayer owner, bool isPromoted = false) - : base(WhichPiece.King, owner, isPromoted) - { - } - public override IEnumerable MoveSet => KingPaths; - } } diff --git a/Shogi.Domain/ValueObjects/Piece.cs b/Shogi.Domain/ValueObjects/Piece.cs index e620d6a..40b534d 100644 --- a/Shogi.Domain/ValueObjects/Piece.cs +++ b/Shogi.Domain/ValueObjects/Piece.cs @@ -1,91 +1,126 @@ -using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing; +using Shogi.Domain.Other; +using Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing; using System.Diagnostics; namespace Shogi.Domain.ValueObjects { - [DebuggerDisplay("{WhichPiece} {Owner}")] - public abstract record class Piece - { - 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 MoveSet { get; } - public WhichPiece WhichPiece { get; } - public WhichPlayer Owner { get; private set; } - public bool IsPromoted { get; private set; } - public bool IsUpsideDown => Owner == WhichPlayer.Player2; + [DebuggerDisplay("{WhichPiece} {Owner}")] + public abstract record class Piece : IRulesLifecycle + { + 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 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; - } + 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 bool CanPromote => !IsPromoted + && WhichPiece != WhichPiece.King + && WhichPiece != WhichPiece.GoldGeneral; - public void Promote() => IsPromoted = CanPromote; + public void Promote() => IsPromoted = CanPromote; - /// - /// Prep the piece for capture by changing ownership and demoting. - /// - public void Capture(WhichPlayer newOwner) - { - Owner = newOwner; - IsPromoted = false; - } + /// + /// Prep the piece for capture by changing ownership and demoting. + /// + public void Capture(WhichPlayer newOwner) + { + Owner = newOwner; + IsPromoted = false; + } - /// - /// 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. - /// - /// - /// - /// An empty list if the piece cannot legally traverse from start to end. Otherwise, a list of positions. - public IEnumerable GetPathFromStartToEnd(Vector2 start, Vector2 end) - { - var steps = new List(10); + /// + /// 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. + /// + /// + /// + /// An empty list if the piece cannot legally traverse from start to end. Otherwise, a list of positions. + public IEnumerable GetPathFromStartToEnd(Vector2 start, Vector2 end) + { + var steps = new List(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); + var path = MoveSet.GetNearestPath(start, end); + var position = start; + while (Vector2.Distance(start, position) < Vector2.Distance(start, end)) + { + position += path.NormalizedDirection; + steps.Add(position); - if (path.Distance == Distance.OneStep) break; - } + if (path.Distance == Distance.OneStep) break; + } - if (position == end) - { - return steps; - } + if (position == end) + { + return steps; + } - return Array.Empty(); - } + return Array.Empty(); + } - /// - /// Get all positions this piece could move to from the currentPosition, respecting the move-set of this piece. - /// - /// - /// A list of positions the piece could move to. - public IEnumerable GetPossiblePositions(Vector2 currentPosition) - { - throw new NotImplementedException(); - } - } + #region IRulesLifecycle + public RulesLifecycleResult OnMoveValidation(MoveValidationContext ctx) + { + var paths = this.MoveSet; + + var matchingPaths = paths.Where(p => p.NormalizedDirection == Vector2.Normalize(ctx.To - ctx.From)); + if (!matchingPaths.Any()) + { + return new RulesLifecycleResult(IsError: true, "Piece cannot move like that."); + } + + var multiStepPaths = matchingPaths.Where(p => p.Distance == Distance.MultiStep).ToArray(); + foreach (var path in multiStepPaths) + { + // Assert that no pieces exist along the from -> to path. + var isPathObstructed = GetPositionsAlongPath(ctx.From, ctx.To, path) + .Any(pos => ctx.BoardState[(int)pos.X, (int)pos.Y] != null); + if (isPathObstructed) + { + return new RulesLifecycleResult(IsError: true, "Piece cannot move through other pieces."); + } + + } + + var pieceAtTo = ctx.BoardState[(int)ctx.To.X, (int)ctx.To.Y]; + if (pieceAtTo?.Owner == this.Owner) + { + return new RulesLifecycleResult(IsError: true, "Cannot capture your own pieces."); + } + + return new RulesLifecycleResult(IsError: false); + + static IEnumerable GetPositionsAlongPath(Vector2 from, Vector2 to, Path path) + { + var next = from; + while (next != to && next.X >= 0 && next.X < 9 && next.Y >= 0 && next.Y < 9) + { + next += path.NormalizedDirection; + yield return next; + } + } + } + #endregion + } } diff --git a/Shogi.Domain/ValueObjects/ShogiBoard.cs b/Shogi.Domain/ValueObjects/ShogiBoard.cs index fbc793c..7328dff 100644 --- a/Shogi.Domain/ValueObjects/ShogiBoard.cs +++ b/Shogi.Domain/ValueObjects/ShogiBoard.cs @@ -1,4 +1,5 @@ using System.Text; +using Shogi.Domain.Other; using Shogi.Domain.YetToBeAssimilatedIntoDDD; namespace Shogi.Domain.ValueObjects; @@ -10,234 +11,257 @@ namespace Shogi.Domain.ValueObjects; /// public sealed class ShogiBoard { - private readonly StandardRules rules; + private readonly StandardRules rules; - public ShogiBoard(BoardState initialState) - { - BoardState = initialState; - rules = new StandardRules(BoardState); - } + public ShogiBoard(BoardState initialState) + { + BoardState = initialState; + rules = new StandardRules(BoardState); + } - public BoardState BoardState { get; } + public BoardState BoardState { get; } - /// - /// Move a piece from a board position to another board position, potentially capturing an opponents piece. Respects all rules of the game. - /// - /// - /// 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. - /// - /// - 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); - } + /// + /// Move a piece from a board position to another board position, potentially capturing an opponents piece. Respects all rules of the game. + /// + /// + /// 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. + /// + /// + public void Move(string from, string to, bool isPromotion = false) + { + // Validate the move + var rulesState = new Piece?[9, 9]; + for (int x = 0; x < 9; x++) + for (int y = 0; y < 9; y++) + { + rulesState[x, y] = this.BoardState[x, y]; + } - // 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 rules = new BoardRules(rulesState); + var validationResult = rules.ValidateMove(Notation.FromBoardNotation(from), Notation.FromBoardNotation(to), isPromotion); + if (validationResult.IsError) + { + throw new InvalidOperationException(validationResult.ResultMessage); + } - if (simulation.DidPlayerPutThemselfInCheck()) - { - throw new InvalidOperationException("Illegal move. This move places you in check."); - } + // Move is valid, but is it legal? + // Check for correct player's turn. + if (BoardState.WhoseTurn != BoardState[from]!.Owner) + { + throw new InvalidOperationException("Not allowed to move the opponent's pieces."); + } - var otherPlayer = BoardState.WhoseTurn == WhichPlayer.Player1 - ? WhichPlayer.Player2 - : WhichPlayer.Player1; - _ = rules.Move(from, to, isPromotion); - if (rules.IsOpponentInCheckAfterMove()) - { - BoardState.InCheck = otherPlayer; - if (rules.IsOpponentInCheckMate()) - { - BoardState.IsCheckmate = true; - } - } - else - { - BoardState.InCheck = null; - } - BoardState.WhoseTurn = otherPlayer; - } + // Simulate the move on a throw -away state and look for "check" and "check-mate". + var simulationState = new BoardState(BoardState); + var moveResult = simulationState.Move(from, to, isPromotion); + if (!moveResult.Success) + { + throw new InvalidOperationException(moveResult.Reason); + } - 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."); - } + var simulation = new StandardRules(simulationState); + // 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."); + } - if (BoardState[to] != null) - { - throw new InvalidOperationException("Illegal placement of piece from the hand. Destination is not empty."); - } + if (simulation.DidPlayerPutThemselfInCheck()) + { + throw new InvalidOperationException("Illegal move. This move places you in check."); + } - 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 otherPlayer = BoardState.WhoseTurn == WhichPlayer.Player1 + ? WhichPlayer.Player2 + : WhichPlayer.Player1; + _ = BoardState.Move(from, to, isPromotion); // "Rules" should not be doing any data changes. "State" should do that. + if (rules.IsOpponentInCheckAfterMove()) + { + BoardState.InCheck = otherPlayer; + if (rules.IsOpponentInCheckMate()) + { + BoardState.IsCheckmate = true; + } + } + else + { + BoardState.InCheck = null; + } + BoardState.WhoseTurn = otherPlayer; + } - var tempBoard = new BoardState(BoardState); - var simulation = new StandardRules(tempBoard); - var moveResult = simulation.Move(pieceInHand, to); - if (!moveResult.Success) - { - throw new InvalidOperationException(moveResult.Reason); - } + 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 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 drop piece becauase you are still in check."); - } + if (BoardState[to] != null) + { + throw new InvalidOperationException("Illegal placement of piece from the hand. Destination is not empty."); + } - if (simulation.DidPlayerPutThemselfInCheck()) - { - throw new InvalidOperationException("Illegal move. This move places you in check."); - } + 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; + } + } - // Update the non-simulation board. - var otherPlayer = tempBoard.WhoseTurn == WhichPlayer.Player1 - ? WhichPlayer.Player2 - : WhichPlayer.Player1; - _ = rules.Move(pieceInHand, to); - if (rules.IsOpponentInCheckAfterMove()) - { - BoardState.InCheck = otherPlayer; - // A pawn, placed from the hand, cannot be the cause of checkmate. - if (rules.IsOpponentInCheckMate() && pieceInHand != WhichPiece.Pawn) - { - BoardState.IsCheckmate = true; - } - } + 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 kingPosition = otherPlayer == WhichPlayer.Player1 - ? tempBoard.Player1KingPosition - : tempBoard.Player2KingPosition; - BoardState.WhoseTurn = otherPlayer; - } + // 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 drop piece becauase you are still in check."); + } - /// - /// Prints a ASCII representation of the board for debugging board state. - /// - /// - 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("- -"); + if (simulation.DidPlayerPutThemselfInCheck()) + { + throw new InvalidOperationException("Illegal move. This move places you in check."); + } - // Print Rank ruler. - builder.AppendLine(); - builder.Append($"{rank + 1} "); + // Update the non-simulation board. + var otherPlayer = tempBoard.WhoseTurn == WhichPlayer.Player1 + ? WhichPlayer.Player2 + : WhichPlayer.Player1; + _ = rules.Move(pieceInHand, to); + if (rules.IsOpponentInCheckAfterMove()) + { + BoardState.InCheck = otherPlayer; + // A pawn, placed from the hand, cannot be the cause of checkmate. + if (rules.IsOpponentInCheckMate() && pieceInHand != WhichPiece.Pawn) + { + BoardState.IsCheckmate = true; + } + } - // 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(); - } + var kingPosition = otherPlayer == WhichPlayer.Player1 + ? tempBoard.Player1KingPosition + : tempBoard.Player2KingPosition; + BoardState.WhoseTurn = otherPlayer; + } - // Horizontal line - builder.Append(" - "); - for (var x = 0; x < 8; x++) builder.Append("- - "); - builder.Append("- -"); - builder.AppendLine(); - builder.Append(" "); - builder.Append("Player 1"); + /// + /// Prints a ASCII representation of the board for debugging board state. + /// + /// + 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("- -"); - builder.AppendLine(); - builder.AppendLine(); - // Print File ruler. - builder.Append(" "); - builder.Append(" A B C D E F G H I "); + // Print Rank ruler. + builder.AppendLine(); + builder.Append($"{rank + 1} "); - return builder.ToString(); - } + // 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(); + } - /// - /// - /// - /// - /// - /// A string with three characters. - /// The first character indicates promotion status. - /// The second character indicates piece. - /// The third character indicates ownership. - /// - private static string ToAscii(Piece piece) - { - var builder = new StringBuilder(); - if (piece.IsPromoted) builder.Append('^'); - else builder.Append(' '); + // Horizontal line + builder.Append(" - "); + for (var x = 0; x < 8; x++) builder.Append("- - "); + builder.Append("- -"); + builder.AppendLine(); + builder.Append(" "); + builder.Append("Player 1"); - 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); + builder.AppendLine(); + builder.AppendLine(); + // Print File ruler. + builder.Append(" "); + builder.Append(" A B C D E F G H I "); - if (piece.Owner == WhichPlayer.Player2) builder.Append('.'); - else builder.Append(' '); + return builder.ToString(); + } - return builder.ToString(); - } + /// + /// + /// + /// + /// + /// A string with three characters. + /// The first character indicates promotion status. + /// The second character indicates piece. + /// The third character indicates ownership. + /// + 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(); + } } diff --git a/Shogi.Domain/ValueObjects/StandardRules.cs b/Shogi.Domain/ValueObjects/StandardRules.cs index 5cd8349..40712e3 100644 --- a/Shogi.Domain/ValueObjects/StandardRules.cs +++ b/Shogi.Domain/ValueObjects/StandardRules.cs @@ -12,105 +12,6 @@ namespace Shogi.Domain.ValueObjects boardState = board; } - /// - /// Move a piece from a board tile to another board tile ignorant of check or check-mate. - /// - /// The position of the piece being moved expressed in board notation. - /// The target position expressed in board notation. - /// True if a promotion is expected as a result of this move. - /// A describing the success or failure of the move. - internal MoveResult Move(string fromNotation, string toNotation, bool isPromotionRequested = false) - { - var from = Notation.FromBoardNotation(fromNotation); - var to = Notation.FromBoardNotation(toNotation); - var fromPiece = boardState[from]; - if (fromPiece == null) - { - return new MoveResult(false, $"Tile [{fromNotation}] is empty. There is no piece to move."); - } - - if (fromPiece.Owner != boardState.WhoseTurn) - { - return new MoveResult(false, "Not allowed to move the opponents piece"); - } - - var path = fromPiece.GetPathFromStartToEnd(from, to); - - if (boardState.IsPathBlocked(path)) - { - return new MoveResult(false, "Another piece obstructs the desired move."); - } - - if (boardState[to] != null) - { - boardState.Capture(to); - } - - if (isPromotionRequested && (boardState.IsWithinPromotionZone(to) || boardState.IsWithinPromotionZone(from))) - { - fromPiece.Promote(); - } - - boardState[to] = fromPiece; - boardState[from] = null; - - boardState.PreviousMove = new Move(from, to); - var otherPlayer = boardState.WhoseTurn == WhichPlayer.Player1 ? WhichPlayer.Player2 : WhichPlayer.Player1; - boardState.WhoseTurn = otherPlayer; - - return new MoveResult(true); - } - - /// Move a piece from the hand to the board ignorant if check or check-mate. - /// - /// - /// The target position expressed in board notation. - /// A describing the success or failure of the simulation. - internal MoveResult Move(WhichPiece pieceInHand, string toNotation) - { - var to = Notation.FromBoardNotation(toNotation); - var index = boardState.ActivePlayerHand.FindIndex(p => p.WhichPiece == pieceInHand); - if (index == -1) - { - return new MoveResult(false, $"{pieceInHand} does not exist in the hand."); - } - if (boardState[to] != null) - { - return new MoveResult(false, "Illegal move - attempting to capture while playing a piece from the hand."); - } - - switch (pieceInHand) - { - case WhichPiece.Knight: - { - // Knight cannot be placed onto the farthest two ranks from the hand. - if (boardState.WhoseTurn == WhichPlayer.Player1 && to.Y > 6 - || boardState.WhoseTurn == WhichPlayer.Player2 && to.Y < 2) - { - return new MoveResult(false, "Knight has no valid moves after placed."); - } - break; - } - case WhichPiece.Lance: - case WhichPiece.Pawn: - { - // Lance and Pawn cannot be placed onto the farthest rank from the hand. - if (boardState.WhoseTurn == WhichPlayer.Player1 && to.Y == 8 - || boardState.WhoseTurn == WhichPlayer.Player2 && to.Y == 0) - { - return new MoveResult(false, $"{pieceInHand} has no valid moves after placed."); - } - break; - } - } - - // Mutate the board. - boardState[to] = boardState.ActivePlayerHand[index]; - boardState.ActivePlayerHand.RemoveAt(index); - boardState.PreviousMove = new Move(pieceInHand, to); - return new MoveResult(true); - } - /// /// Determines if the last move put the player who moved in check. /// @@ -238,14 +139,16 @@ namespace Shogi.Domain.ValueObjects return true; } - private IList GetPossiblePositionsForKing(WhichPlayer whichPlayer) + private List GetPossiblePositionsForKing(WhichPlayer whichPlayer) { var kingPosition = whichPlayer == WhichPlayer.Player1 ? boardState.Player1KingPosition : boardState.Player2KingPosition; - return King.KingPaths - .Select(path => path.Direction + kingPosition) + var paths = boardState[kingPosition]!.MoveSet; + return paths + .Select(path => path.NormalizedDirection + kingPosition) + // Because the king could be on the edge of the board, where some of its paths do not make sense. .Where(newPosition => newPosition.IsInsideBoardBoundary()) // Where tile at position is empty, meaning the king could move there. .Where(newPosition => boardState[newPosition] == null) diff --git a/Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Distance.cs b/Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Distance.cs index e9a7ff2..915f3af 100644 --- a/Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Distance.cs +++ b/Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Distance.cs @@ -1,8 +1,13 @@ -namespace Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing +namespace Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing; + +public enum Distance { - public enum Distance - { - OneStep, - MultiStep - } + /// + /// Signifies that a piece can move one tile/position per move. + /// + OneStep, + /// + /// Signifies that a piece can move multiple tiles/positions in a single move. + /// + MultiStep } \ No newline at end of file diff --git a/Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Path.cs b/Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Path.cs index 3fba1a6..765c1f7 100644 --- a/Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Path.cs +++ b/Shogi.Domain/YetToBeAssimilatedIntoDDD/Pathing/Path.cs @@ -3,10 +3,17 @@ namespace Shogi.Domain.YetToBeAssimilatedIntoDDD.Pathing; [DebuggerDisplay("{Direction} - {Distance}")] -public record Path(Vector2 Direction, Distance Distance = Distance.OneStep) +public record Path { + public Vector2 NormalizedDirection { get; } + public Distance Distance { get; } - public Path Invert() => new(Vector2.Negate(Direction), Distance); + public Path(Vector2 direction, Distance distance = Distance.OneStep) + { + NormalizedDirection = Vector2.Normalize(direction); + this.Distance = distance; + } + public Path Invert() => new(Vector2.Negate(NormalizedDirection), Distance); } public static class PathExtensions @@ -21,8 +28,8 @@ public static class PathExtensions var shortestPath = paths.First(); foreach (var path in paths.Skip(1)) { - var distance = Vector2.Distance(start + path.Direction, end); - var shortestDistance = Vector2.Distance(start + shortestPath.Direction, end); + var distance = Vector2.Distance(start + path.NormalizedDirection, end); //Normalizing the direction probably broke this. + var shortestDistance = Vector2.Distance(start + shortestPath.NormalizedDirection, end); // And this. if (distance < shortestDistance) { shortestPath = path;