checkpoint

This commit is contained in:
2025-09-05 18:13:35 -05:00
parent e2a8b771d9
commit 0a415a2292
24 changed files with 622 additions and 492 deletions

View File

@@ -0,0 +1,390 @@
using Shogi.Domain.ValueObjects.Movement;
using Shogi.Domain.YetToBeAssimilatedIntoDDD;
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(BoardState initialState)
{
private static readonly int[] zeroToEight = [0, 1, 2, 3, 4, 5, 6, 7, 8];
private static readonly Vector2 BoardSize = new(9, 9);
public BoardState BoardState { get; } = initialState;
/// <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 MoveResult Move(string from, string to, bool isPromotion)
{
// Validate the move
var moveResult = IsMoveValid(Notation.FromBoardNotation(from), Notation.FromBoardNotation(to));
if (!moveResult.IsSuccess)
{
return moveResult;
}
// Move is valid, but is it legal?
// Check for correct player's turn.
if (BoardState.WhoseTurn != BoardState[from]!.Owner)
{
return new MoveResult(false, "Not allowed to move the opponent's pieces.");
}
// Simulate the move on a throw-away state and look for "check" and "check-mate".
var simState = new BoardState(BoardState);
moveResult = simState.Move(from, to, isPromotion);
if (!moveResult.IsSuccess)
{
return moveResult;
}
// Look for threats against the kings.
InCheckResult inCheckResult = IsEitherPlayerInCheck(simState);
var playerPutThemselfInCheck = BoardState.WhoseTurn == WhichPlayer.Player1
? inCheckResult.HasFlag(InCheckResult.Player1InCheck)
: inCheckResult.HasFlag(InCheckResult.Player2InCheck);
if (playerPutThemselfInCheck)
{
return new MoveResult(false, "This move puts the moving player in check, which is illegal.");
}
var playerPutOpponentInCheck = BoardState.WhoseTurn == WhichPlayer.Player1
? inCheckResult.HasFlag(InCheckResult.Player2InCheck)
: inCheckResult.HasFlag(InCheckResult.Player1InCheck);
// Move is legal; mutate the real state.
if (playerPutOpponentInCheck)
{
BoardState.InCheck = BoardState.WhoseTurn == WhichPlayer.Player1
? WhichPlayer.Player2
: WhichPlayer.Player1;
}
else if (inCheckResult == InCheckResult.NobodyInCheck)
{
BoardState.InCheck = null;
}
BoardState.Move(from, to, isPromotion);
if (BoardState.InCheck.HasValue)
{
var gameOverResult = EvaluateGameOver();
BoardState.IsCheckmate = gameOverResult switch
{
GameOverResult.GameIsNotOver => false,
GameOverResult.Player1Wins => true,
GameOverResult.Player2Wins => true,
_ => throw new InvalidOperationException("Unexpected GameOverResult value.")
};
}
return new MoveResult(true);
}
public MoveResult Move(WhichPiece pieceInHand, string to)
{
if (BoardState.IsCheckmate)
{
return new MoveResult(false, "The game is over. A winner has been decided.");
}
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, $"Tried to play a piece from the hand to an occupied position.");
}
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)
{
return new MoveResult(false, "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)
{
return new MoveResult(false, $"Illegal move. {pieceInHand} has no valid moves after placement.");
}
break;
}
}
if (pieceInHand == WhichPiece.Pawn)
{
// Pawns cannot be placed into a column with another unpromoted pawn controlled by the moving player.
var columnAlreadyHasPawn = zeroToEight
.Select(y => BoardState[new Vector2(toVector.X, y)])
.Where(piece => piece?.WhichPiece == WhichPiece.Pawn)
.Where(piece => piece?.Owner == BoardState.WhoseTurn)
.Where(piece => piece?.IsPromoted == false)
.Any();
if (columnAlreadyHasPawn)
{
return new MoveResult(false, "A player may not have two unpromoted pawns in the same file.");
}
}
var simState = new BoardState(BoardState);
var moveResult = simState.Move(pieceInHand, to);
if (!moveResult.IsSuccess)
{
return moveResult;
}
var inCheckResult = IsEitherPlayerInCheck(simState);
var playerPutThemselfInCheck = BoardState.WhoseTurn == WhichPlayer.Player1
? inCheckResult.HasFlag(InCheckResult.Player1InCheck)
: inCheckResult.HasFlag(InCheckResult.Player2InCheck);
if (playerPutThemselfInCheck)
{
return new MoveResult(false, "This move puts the moving player in check, which is illegal.");
}
var playerPutOpponentInCheck = BoardState.WhoseTurn == WhichPlayer.Player1
? inCheckResult.HasFlag(InCheckResult.Player2InCheck)
: inCheckResult.HasFlag(InCheckResult.Player1InCheck);
// Move is legal; mutate the real state.
if (playerPutOpponentInCheck)
{
BoardState.InCheck = BoardState.WhoseTurn == WhichPlayer.Player1
? WhichPlayer.Player2
: WhichPlayer.Player1;
}
else if (inCheckResult == InCheckResult.NobodyInCheck)
{
BoardState.InCheck = null;
}
BoardState.Move(pieceInHand, to);
// A pawn, placed from the hand, cannot be the cause of checkmate.
if (BoardState.InCheck.HasValue && pieceInHand != WhichPiece.Pawn)
{
var gameOverResult = EvaluateGameOver();
BoardState.IsCheckmate = gameOverResult switch
{
GameOverResult.GameIsNotOver => false,
GameOverResult.Player1Wins => true,
GameOverResult.Player2Wins => true,
_ => throw new InvalidOperationException("Unexpected GameOverResult value.")
};
}
return new MoveResult(true);
}
private GameOverResult EvaluateGameOver()
{
if (!BoardState.InCheck.HasValue)
{
return GameOverResult.GameIsNotOver;
}
var kingInCheck = BoardState.State
.Single(kvp => kvp.Value?.WhichPiece == WhichPiece.King && kvp.Value?.Owner == BoardState.InCheck);
var kingInCheckVectorPosition = Notation.FromBoardNotation(kingInCheck.Key);
var piecesOfPlayerInCheck = BoardState.State
.Where(kvp => kvp.Value?.Owner == BoardState.InCheck)
.Cast<KeyValuePair<string, Piece>>();
foreach (var (notation, piece) in piecesOfPlayerInCheck)
{
// Get possible locations this piece could move to.
var allPossibleMoves = GetPossiblePositionsForPiece(Notation.FromBoardNotation(notation), piece);
// Try to make a legal move, disproving checkmate.
foreach (var move in allPossibleMoves)
{
var simState = new BoardState(BoardState);
simState.Move(notation, Notation.ToBoardNotation(move), false);
var inCheckResult = IsEitherPlayerInCheck(simState);
var isStillInCheck = BoardState.InCheck == WhichPlayer.Player1
? inCheckResult.HasFlag(InCheckResult.Player1InCheck)
: inCheckResult.HasFlag(InCheckResult.Player2InCheck);
if (!isStillInCheck)
{
return GameOverResult.GameIsNotOver;
}
}
}
return BoardState.InCheck == WhichPlayer.Player1
? GameOverResult.Player2Wins
: GameOverResult.Player1Wins;
Vector2[] GetPossiblePositionsForPiece(Vector2 piecePosition, Piece piece)
{
var paths = piece.MoveSet;
return
paths
.SelectMany(path =>
{
var list = new List<Vector2>(10);
var position = path.Step + piecePosition;
if (path.Distance == Distance.MultiStep)
{
while (position.IsInsideBoardBoundary())
{
list.Add(position);
position += path.Step;
}
}
else if (position.IsInsideBoardBoundary())
{
list.Add(position);
}
return list;
})
// Where tile at position is empty, meaning the piece could move there.
.Where(newPosition => BoardState[newPosition] == null)
.ToArray();
}
}
private static InCheckResult IsEitherPlayerInCheck(BoardState simState)
{
var kings = simState.State
.Where(kvp => kvp.Value?.WhichPiece == WhichPiece.King)
.Cast<KeyValuePair<string, Piece>>()
.ToArray();
if (kings.Length != 2) throw new InvalidOperationException("Unexpected scenario: board does not have two kings in play.");
return simState.State
.Where(kvp => kvp.Value != null)
.Cast<KeyValuePair<string, Piece>>()
.Aggregate(InCheckResult.NobodyInCheck, (inCheckResult, kvp) =>
{
var newInCheckResult = inCheckResult;
var threatPiece = kvp.Value;
var opposingKingPosition = Notation.FromBoardNotation(kings.Single(king => king.Value.Owner != threatPiece.Owner).Key);
var positionsThreatened = threatPiece.GetPathFromStartToEnd(Notation.FromBoardNotation(kvp.Key), opposingKingPosition);
foreach (var position in positionsThreatened)
{
// No piece at this position, so pathing is unobstructed. Continue pathing.
if (simState[position] == null) continue;
var threatenedPiece = simState[position]!;
if (threatenedPiece.WhichPiece == WhichPiece.King && threatenedPiece.Owner != threatPiece.Owner)
{
newInCheckResult |= threatenedPiece.Owner == WhichPlayer.Player1 ? InCheckResult.Player1InCheck : InCheckResult.Player2InCheck;
}
else
{
break;
}
}
return newInCheckResult;
});
}
/// <summary>
/// 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 according to all Shogi rules.
/// It asserts that a proposed move is possible and worthy of further validation (check, check-mate, etc).
/// </summary>
private MoveResult IsMoveValid(Vector2 from, Vector2 to)
{
if (IsWithinBounds(from) && IsWithinBounds(to))
{
if (BoardState[to]?.WhichPiece == WhichPiece.King)
{
return new MoveResult(false, "Kings may not be captured.");
}
var piece = BoardState[from];
if (piece == null)
{
return new MoveResult(false, $"There is no piece at position {from}.");
}
var clampedFromTo = Vector2.Clamp(to - from, -Vector2.One, Vector2.One);
var matchingPaths = piece.MoveSet.Where(p => p.Step == clampedFromTo);
if (Vector2.Distance(to, from) < 2)
{
if (!matchingPaths.Any())
{
return new MoveResult(false, "Piece cannot move like that.");
}
}
else
{
var multiStepPaths = matchingPaths
.Where(path => path.Distance == Distance.MultiStep)
.ToArray();
if (multiStepPaths.Length == 0)
{
return new MoveResult(false, "Piece cannot move like that");
}
foreach (var path in multiStepPaths)
{
// Assert that no pieces exist along the from -> to path.
var isPathObstructed = GetPositionsAlongPath(from, to, path)
.SkipLast(1)
.Any(pos => BoardState[pos] != null);
if (isPathObstructed)
{
return new MoveResult(false, "Piece cannot move through other pieces.");
}
}
}
var pieceAtTo = BoardState[to];
if (pieceAtTo?.Owner == piece.Owner)
{
return new MoveResult(false, "Cannot capture your own pieces.");
}
}
return new MoveResult(true);
}
private static IEnumerable<Vector2> 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.Step;
yield return next;
}
}
private static bool IsWithinBounds(Vector2 position)
{
var isPositive = position - position == Vector2.Zero;
return isPositive && position.X <= BoardSize.X && position.Y <= BoardSize.Y;
}
}