From b3453871646dfeb46867622b788f24140c761a06 Mon Sep 17 00:00:00 2001 From: Fokinator Date: Mon, 9 Mar 2026 20:22:06 +0300 Subject: [PATCH] Initial commit --- .gitignore | 36 ++ lombok.config | 1 + pom.xml | 96 +++ .../ru/fokinatorr/chess/impl/ChessBoard.java | 362 +++++++++++ .../ru/fokinatorr/chess/impl/ChessUtil.java | 164 +++++ .../ru/fokinatorr/chess/impl/MoveEntry.java | 18 + .../ru/fokinatorr/chess/impl/MoveParser.java | 105 +++ .../ru/fokinatorr/chess/impl/PieceColor.java | 14 + .../chess/impl/PieceColorOrRandom.java | 25 + .../ru/fokinatorr/chess/impl/Position.java | 275 ++++++++ .../impl/misc/ChessBoardAndMoveStack.java | 285 +++++++++ .../chess/impl/movestack/CastlingMove.java | 116 ++++ .../chess/impl/movestack/EnPassantMove.java | 123 ++++ .../fokinatorr/chess/impl/movestack/Move.java | 37 ++ .../chess/impl/movestack/MoveStack.java | 88 +++ .../chess/impl/movestack/NoOpMove.java | 47 ++ .../impl/movestack/PawnPromotionMove.java | 129 ++++ .../chess/impl/movestack/SimpleMove.java | 119 ++++ .../fokinatorr/chess/impl/package-info.java | 2 + .../fokinatorr/chess/impl/piece/Bishop.java | 37 ++ .../chess/impl/piece/ConditionalMovement.java | 116 ++++ .../ru/fokinatorr/chess/impl/piece/King.java | 39 ++ .../fokinatorr/chess/impl/piece/Knight.java | 35 + .../ru/fokinatorr/chess/impl/piece/Pawn.java | 58 ++ .../ru/fokinatorr/chess/impl/piece/Piece.java | 60 ++ .../ru/fokinatorr/chess/impl/piece/Queen.java | 43 ++ .../ru/fokinatorr/chess/impl/piece/Rook.java | 35 + .../java/ru/fokinatorr/chess/misc/Pair.java | 8 + .../ru/fokinatorr/chess/server/UserDTO.java | 9 + .../CannotAcceptOrRefuseDrawException.java | 10 + .../DrawAlreadyOfferedException.java | 20 + .../exception/DrawNotOfferedException.java | 20 + .../exception/GameAccessDeniedException.java | 22 + .../exception/GameCancelledException.java | 19 + .../exception/GameNotFoundException.java | 22 + .../exception/InheritedResponseStatus.java | 30 + .../exception/InvalidMoveException.java | 22 + .../exception/MoveAlreadyDoneException.java | 16 + .../server/exception/MoveOrderException.java | 11 + .../exception/NotParticipantException.java | 11 + .../exception/UserNotFoundException.java | 20 + .../game/ChessBoardAndMoveStackDTO.java | 41 ++ .../chess/server/game/CreateGameOptions.java | 10 + .../chess/server/game/FinishedGameDTO.java | 19 + .../chess/server/game/OngoingGameDTO.java | 15 + .../chess/server/game/PendingGameDTO.java | 15 + .../server/game/event/DrawOfferedEvent.java | 8 + .../server/game/event/DrawRefusedEvent.java | 8 + .../chess/server/game/event/GameEvent.java | 29 + .../server/game/event/GameFinishedEvent.java | 30 + .../server/game/event/GameJoinedEvent.java | 15 + .../server/game/event/MovePerformedEvent.java | 9 + .../server/game/event/MoveRedoneEvent.java | 8 + .../server/game/event/MoveUndoneEvent.java | 8 + .../fokinatorr/chess/server/package-info.java | 2 + .../server/security/LoginRequirements.java | 14 + .../chess/server/security/RegisterForm.java | 5 + .../chess/server/service/DTOService.java | 42 ++ .../chess/util/ThrowingRunnable.java | 41 ++ .../ru/fokinatorr/chess/ChessLogicTests.java | 600 ++++++++++++++++++ 60 files changed, 3624 insertions(+) create mode 100644 .gitignore create mode 100644 lombok.config create mode 100644 pom.xml create mode 100644 src/main/java/ru/fokinatorr/chess/impl/ChessBoard.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/ChessUtil.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/MoveEntry.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/MoveParser.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/PieceColor.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/PieceColorOrRandom.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/Position.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/misc/ChessBoardAndMoveStack.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/movestack/CastlingMove.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/movestack/EnPassantMove.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/movestack/Move.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/movestack/MoveStack.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/movestack/NoOpMove.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/movestack/PawnPromotionMove.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/movestack/SimpleMove.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/package-info.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/piece/Bishop.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/piece/ConditionalMovement.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/piece/King.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/piece/Knight.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/piece/Pawn.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/piece/Piece.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/piece/Queen.java create mode 100644 src/main/java/ru/fokinatorr/chess/impl/piece/Rook.java create mode 100644 src/main/java/ru/fokinatorr/chess/misc/Pair.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/UserDTO.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/exception/CannotAcceptOrRefuseDrawException.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/exception/DrawAlreadyOfferedException.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/exception/DrawNotOfferedException.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/exception/GameAccessDeniedException.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/exception/GameCancelledException.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/exception/GameNotFoundException.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/exception/InheritedResponseStatus.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/exception/InvalidMoveException.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/exception/MoveAlreadyDoneException.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/exception/MoveOrderException.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/exception/NotParticipantException.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/exception/UserNotFoundException.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/game/ChessBoardAndMoveStackDTO.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/game/CreateGameOptions.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/game/FinishedGameDTO.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/game/OngoingGameDTO.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/game/PendingGameDTO.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/game/event/DrawOfferedEvent.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/game/event/DrawRefusedEvent.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/game/event/GameEvent.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/game/event/GameFinishedEvent.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/game/event/GameJoinedEvent.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/game/event/MovePerformedEvent.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/game/event/MoveRedoneEvent.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/game/event/MoveUndoneEvent.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/package-info.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/security/LoginRequirements.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/security/RegisterForm.java create mode 100644 src/main/java/ru/fokinatorr/chess/server/service/DTOService.java create mode 100644 src/main/java/ru/fokinatorr/chess/util/ThrowingRunnable.java create mode 100644 src/test/java/ru/fokinatorr/chess/ChessLogicTests.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d0f1d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### IntelliJ IDEA ### +.idea +.gigaide +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..bd4a8cf --- /dev/null +++ b/lombok.config @@ -0,0 +1 @@ +lombok.copyableAnnotations += com.fasterxml.jackson.annotation.JsonProperty \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..80634ae --- /dev/null +++ b/pom.xml @@ -0,0 +1,96 @@ + + + 4.0.0 + + ru.fokinatorr + chess.api + 0.0.2-SNAPSHOT + Ch4ss API + Common stuff used by both client and server + + + 23 + 23 + UTF-8 + + + + org.projectlombok + lombok + 1.18.38 + compile + + + org.slf4j + slf4j-api + 2.0.17 + + + ch.qos.logback + logback-core + 1.5.18 + + + ch.qos.logback + logback-classic + 1.5.18 + + + com.fasterxml.jackson.core + jackson-annotations + 2.18.3 + + + com.fasterxml.jackson.core + jackson-databind + 2.18.3 + + + org.junit.jupiter + junit-jupiter + 5.11.4 + test + + + org.springframework + spring-web + 6.2.6 + true + + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + attach-sources + verify + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + org.projectlombok + lombok + 1.18.38 + + + + + + + \ No newline at end of file diff --git a/src/main/java/ru/fokinatorr/chess/impl/ChessBoard.java b/src/main/java/ru/fokinatorr/chess/impl/ChessBoard.java new file mode 100644 index 0000000..9bf1d2c --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/ChessBoard.java @@ -0,0 +1,362 @@ +package ru.fokinatorr.chess.impl; + +import lombok.AccessLevel; +import lombok.Data; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import ru.fokinatorr.chess.impl.movestack.*; +import ru.fokinatorr.chess.impl.piece.*; + +import java.util.*; +import java.util.function.Function; + +/// A chess board containing pieces. +@Slf4j +@Data +@FieldDefaults(level = AccessLevel.PRIVATE) +public class ChessBoard implements java.io.Serializable, Cloneable { + + @java.io.Serial + private static final long serialVersionUID = 2600172883193074936L; + + final Map pieces = new HashMap<>(); + + PieceColor currentlyMoving = PieceColor.WHITE; + transient Function pawnPromoter; + + /// Returns a default starting position, which looks like this: + ///

+ public static ChessBoard startPosition() { + ChessBoard chessBoard = new ChessBoard(); + chessBoard.setPiece(Position.valueOf("a1"), new Rook(PieceColor.WHITE)); + chessBoard.setPiece(Position.valueOf("b1"), new Knight(PieceColor.WHITE)); + chessBoard.setPiece(Position.valueOf("c1"), new Bishop(PieceColor.WHITE)); + chessBoard.setPiece(Position.valueOf("d1"), new Queen(PieceColor.WHITE)); + chessBoard.setPiece(Position.valueOf("e1"), new King(PieceColor.WHITE)); + chessBoard.setPiece(Position.valueOf("f1"), new Bishop(PieceColor.WHITE)); + chessBoard.setPiece(Position.valueOf("g1"), new Knight(PieceColor.WHITE)); + chessBoard.setPiece(Position.valueOf("h1"), new Rook(PieceColor.WHITE)); + chessBoard.setPiece(Position.valueOf("a2"), new Pawn(PieceColor.WHITE)); + chessBoard.setPiece(Position.valueOf("b2"), new Pawn(PieceColor.WHITE)); + chessBoard.setPiece(Position.valueOf("c2"), new Pawn(PieceColor.WHITE)); + chessBoard.setPiece(Position.valueOf("d2"), new Pawn(PieceColor.WHITE)); + chessBoard.setPiece(Position.valueOf("e2"), new Pawn(PieceColor.WHITE)); + chessBoard.setPiece(Position.valueOf("f2"), new Pawn(PieceColor.WHITE)); + chessBoard.setPiece(Position.valueOf("g2"), new Pawn(PieceColor.WHITE)); + chessBoard.setPiece(Position.valueOf("h2"), new Pawn(PieceColor.WHITE)); + + chessBoard.setPiece(Position.valueOf("a8"), new Rook(PieceColor.BLACK)); + chessBoard.setPiece(Position.valueOf("b8"), new Knight(PieceColor.BLACK)); + chessBoard.setPiece(Position.valueOf("c8"), new Bishop(PieceColor.BLACK)); + chessBoard.setPiece(Position.valueOf("d8"), new Queen(PieceColor.BLACK)); + chessBoard.setPiece(Position.valueOf("e8"), new King(PieceColor.BLACK)); + chessBoard.setPiece(Position.valueOf("f8"), new Bishop(PieceColor.BLACK)); + chessBoard.setPiece(Position.valueOf("g8"), new Knight(PieceColor.BLACK)); + chessBoard.setPiece(Position.valueOf("h8"), new Rook(PieceColor.BLACK)); + chessBoard.setPiece(Position.valueOf("a7"), new Pawn(PieceColor.BLACK)); + chessBoard.setPiece(Position.valueOf("b7"), new Pawn(PieceColor.BLACK)); + chessBoard.setPiece(Position.valueOf("c7"), new Pawn(PieceColor.BLACK)); + chessBoard.setPiece(Position.valueOf("d7"), new Pawn(PieceColor.BLACK)); + chessBoard.setPiece(Position.valueOf("e7"), new Pawn(PieceColor.BLACK)); + chessBoard.setPiece(Position.valueOf("f7"), new Pawn(PieceColor.BLACK)); + chessBoard.setPiece(Position.valueOf("g7"), new Pawn(PieceColor.BLACK)); + chessBoard.setPiece(Position.valueOf("h7"), new Pawn(PieceColor.BLACK)); + + return chessBoard; + } + + + /// Get a piece on the given position. + public Piece getPiece(Position pos) { + return pieces.get(pos); + } + + /// Get all pieces in the given column. + public synchronized List getPieces(Position.Column column) { + List result = new ArrayList<>(); + for (Position.Row row : Position.Row.values()) { + Piece piece = getPiece(Position.valueOf(column, row)); + if (piece != null) result.add(piece); + } + return Collections.unmodifiableList(result); + } + + /// Get all pieces in the given row. + public synchronized List getPieces(Position.Row row) { + List result = new ArrayList<>(); + for (Position.Column column : Position.Column.values()) { + Piece piece = getPiece(Position.valueOf(column, row)); + if (piece != null) result.add(piece); + } + return Collections.unmodifiableList(result); + } + + /// Returns the pieces of this chess board as position mapped to piece. + public synchronized Map getPieces() { + return Map.copyOf(pieces); + } + + /// Clears the pieces from this chess board and adds all the given ones. + public synchronized void setPieces(Map newPieces) { + pieces.clear(); + pieces.putAll(newPieces); + } + + /// Place the given piece on the given position and return the old one. + /// Removes the piece at the given position if `newPiece` is `null`. + /// @throws NullPointerException if `pos` is `null` + public synchronized Piece setPiece(Position pos, Piece newPiece) { + Objects.requireNonNull(pos, "Position must not be null"); + log.info("Set piece {} on position {}", newPiece, pos); + return newPiece == null ? pieces.remove(pos) : pieces.put(pos, newPiece); + } + + /// Determines if a piece on the given position, if it exists, is of given color. + public boolean isPieceOfColor(Position pos, PieceColor color) { + Piece piece = getPiece(pos); + return piece != null && piece.getColor() == color; + } + + /// Determines if a piece on the given position, if it exists, is of given type. + public boolean isPieceOfType(Position pos, Class type) { + Piece piece = getPiece(pos); + return piece != null && type.isAssignableFrom(piece.getClass()); + } + + /// Determines if a piece on the given position, if it exists, is of given color. + public boolean isPieceOfColorAndType(Position pos, PieceColor color, Class type) { + Piece piece = getPiece(pos); + return piece != null && piece.getColor() == color && type.isAssignableFrom(piece.getClass()); + } + + /// Determines if a piece on the given position exists. + public boolean isPresent(Position pos) { + return getPiece(pos) != null; + } + + /// Determines if a piece on the given position does not exist. + public boolean isEmpty(Position pos) { + return getPiece(pos) == null; + } + + /// Reverse the currently moving player. + public void reverseCurrentlyMoving() { + setCurrentlyMoving(currentlyMoving.reverse()); + } + + /// Find the actual positions that a piece on position `pos`, if it's present, can reach, and return them + /// as an immutable [Collection]. If no piece is present at the given position, returns an empty `Collection`. + /// @throws NullPointerException if any argument is `null` + public Collection getMoves(Position pos, MoveStack moveStack) { + if (isEmpty(Objects.requireNonNull(pos, "pos"))) { + return Set.of(); + } + Collection toFilter = getMovesNoKingCheck(pos, moveStack, true); + PieceColor pieceColor = getPiece(pos).getColor(); + var iterator = toFilter.iterator(); + while (iterator.hasNext()) { + MoveEntry moveEntry = iterator.next(); + Move move = moveEntry.move(); + if (move instanceof PawnPromotionMove pawnPromotionMove) { + pawnPromotionMove.setPawnPromoter(Queen::new); + } + move.perform(this, moveStack); + if (isCheck(pieceColor)) { + iterator.remove(); + } + move.undo(this); + if (move instanceof PawnPromotionMove pawnPromotionMove) { + pawnPromotionMove.setPawnPromoter(pawnPromoter); + } + } + return Set.copyOf(toFilter); + } + + + /// Find the actual positions that a piece on position `pos`, if it's present, can reach, and return them + /// as a mutable [Collection]. If no piece is present at the given position, returns an empty `Collection`. + ///

+ /// Note that this method does not check if the king is going to be attacked after one of these doneMoves. + /// @throws NullPointerException if `pos` argument is `null`, or if `moveStack` is `null` and `checkCastlingAndEnPassant` is `true` + private Collection getMovesNoKingCheck(Position pos, MoveStack moveStack, boolean checkCastlingAndEnPassant) { + if (isEmpty(Objects.requireNonNull(pos, "pos"))) { + return new HashSet<>(); + } + Collection result = new HashSet<>(); + Piece piece = getPiece(pos); + Collection> potentialMoves = piece.getPotentialMoves(pos); + for (Collection movementChain : potentialMoves) { + for (ConditionalMovement conditionalMovement : movementChain) { + if (conditionalMovement.moveCondition() == ConditionalMovement.MoveCondition.CASTLING) { + // at this point it's known that the piece is king + if (checkCastlingAndEnPassant && piece instanceof King king && king.isNeverMoved()) { + Piece maybeRook; // the rook that's going to take part in castling + Position kingJumpPos; // the position that king jumps over. should not be attacked + Position.Column rookColumn; + boolean right = conditionalMovement.pos().getColumn().getIndex() - pos.getColumn().getIndex() > 0; + + if (right) { + kingJumpPos = pos.right(1); + maybeRook = getPiece(Position.valueOf(rookColumn = Position.Column.H, pos.getRow())); + } else { + kingJumpPos = pos.left(1); + maybeRook = getPiece(Position.valueOf(rookColumn = Position.Column.A, pos.getRow())); + } + + // the color of the rook doesn't matter because: + // 1. if it's the opposite color, the king is in check => castling can't be done; + // 2. if it's the opposite color and the king isn't in check, there's a piece between them => castling can't be done. + + if (maybeRook instanceof Rook rook && rook.isNeverMoved() // there's a piece, it's a rook and it never moved before + && !isPositionAttacked(pos, king.getColor().reverse()) // it's not check currently + && !isPositionAttacked(kingJumpPos, king.getColor().reverse())) { // the position that king "jumps" over is not attacked too + assert kingJumpPos != null; + // check all the pieces between the king and the rook + boolean noPiecesBetween = true; + for (Position.Column column = kingJumpPos.getColumn(); + Math.min(column.getIndex(), rookColumn.getIndex()) < Math.max(column.getIndex(), rookColumn.getIndex()); + column = Position.Column.valueOf(column.getIndex() + (right ? 1 : -1))) { + noPiecesBetween &= isEmpty(Position.valueOf(column, conditionalMovement.pos().getRow())); + } + + if (noPiecesBetween) { + result.add(new MoveEntry(conditionalMovement.pos(), + new CastlingMove(pos, Position.valueOf(rookColumn, pos.getRow()), right), + false)); + } + } + } + break; + } else if (piece instanceof Pawn pawn + && pos.getRow() == pawn.getEnPassantRow() + && conditionalMovement.moveCondition().canCapture() + && isEmpty(conditionalMovement.pos()) + && checkCastlingAndEnPassant) { + Position capturePos = conditionalMovement.pos().down(pawn.getForwardMovementFactor()); + if (isPieceOfColorAndType(capturePos, pawn.getColor().reverse(), Pawn.class) + && ChessUtil.wasPawnDoubleMove(moveStack.peekDone(), capturePos, pawn.getColor().reverse())) { + result.add(new MoveEntry( + conditionalMovement.pos(), + new EnPassantMove(pos, conditionalMovement.pos().getColumn().getIndex() > pos.getColumn().getIndex()), + // can't be attack since conditionalMovement.pos() is empty + false + )); + break; + } + } else if (isPieceOfColor(conditionalMovement.pos(), piece.getColor()) + || !conditionalMovement.moveCondition().canMove() && isEmpty(conditionalMovement.pos()) + || !conditionalMovement.moveCondition().canCapture() && isPresent(conditionalMovement.pos())) { + break; + } else { + if (piece instanceof Pawn pawn) { + if (conditionalMovement.pos().getRow() == pawn.getPromotionRow()) { + result.add(new MoveEntry( + conditionalMovement.pos(), + new PawnPromotionMove(pos, conditionalMovement.pos(), pawnPromoter), + conditionalMovement.moveCondition().canCapture() + )); + break; + } + } + result.add(new MoveEntry( + conditionalMovement.pos(), + new SimpleMove(pos, conditionalMovement.pos()), + conditionalMovement.moveCondition().canCapture() + )); + if (isPieceOfColor(conditionalMovement.pos(), piece.getColor().reverse())) { + break; + } + } + } + } + return result; + } + + /// Determines whether the given position is attacked by any piece of the given color. + /// @throws NullPointerException if any argument is `null` + public boolean isPositionAttacked(Position givenPos, PieceColor color) { + Objects.requireNonNull(givenPos, "pos"); + Objects.requireNonNull(color, "color"); + for (Position pos : pieces.keySet()) { + if (isPieceOfColor(pos, color)) { + for (MoveEntry move : getMovesNoKingCheck(pos, null, false)) { + if (move.isAttack() && move.destPos().equals(givenPos)) { + return true; + } + } + } + } + return false; + } + + /// Determines whether a king of the given color is attacked. + /// @throws InternalError if a king of the given color is not present on this board + public boolean isCheck(PieceColor kingColor) { + // find the king + Position kingPos = null; + for (var posPieceEntry : pieces.entrySet()) { + if (posPieceEntry.getValue() instanceof King king && king.getColor() == kingColor) { + kingPos = posPieceEntry.getKey(); + break; + } + } + if (kingPos == null) { + throw new InternalError("No " + kingColor.name().toLowerCase(Locale.ENGLISH) + " King found on " + this); + } + return isPositionAttacked(kingPos, kingColor.reverse()); + } + + /// Determines whether no piece of the given color has a legal move. Common check for checkmate and stalemate. + private boolean noPieceOfColorCanMove(PieceColor color, MoveStack moveStack) { + for (Position pos : pieces.keySet().toArray(new Position[0])) { + if (isPieceOfColor(pos, color) && !getMoves(pos, moveStack).isEmpty()) { + return false; + } + } + return true; + } + + /// Determines whether a king of the given color is in check and no piece of the given color has a legal move. + public boolean isCheckmate(PieceColor color, MoveStack moveStack) { + return isCheck(color) && noPieceOfColorCanMove(color, moveStack); + } + + /// Determines whether no piece of the given color has a legal move, but a king of the given color is not in check. + public boolean isStalemate(PieceColor color, MoveStack moveStack) { + return !isCheck(color) && noPieceOfColorCanMove(color, moveStack); + } + + /// Creates and returns a deep copy of this chess board. + @SuppressWarnings("MethodDoesntCallSuperMethod") + @Override + public ChessBoard clone() { + log.info("Cloning board"); + ChessBoard clone = new ChessBoard(); + clone.pawnPromoter = this.pawnPromoter; + clone.currentlyMoving = this.currentlyMoving; + for (Position pos : pieces.keySet()) { + clone.setPiece(pos, getPiece(pos).clone()); + } + return clone; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(" ABCDEFGH"); + for (int i = 8; i >= 1; i--) { + sb.append('\n').append(i); + for (Piece piece : getPieces(Position.Row.valueOfNumber(i))) { + char pieceNotation = switch (piece) { + case Bishop _ -> 'B'; + case King _ -> 'K'; + case Knight _ -> 'N'; + case Pawn _ -> 'P'; + case Queen _ -> 'Q'; + case Rook _ -> 'R'; + }; + sb.append(piece.getColor() == PieceColor.BLACK ? Character.toLowerCase(pieceNotation) : pieceNotation); + } + } + return sb.toString(); + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/ChessUtil.java b/src/main/java/ru/fokinatorr/chess/impl/ChessUtil.java new file mode 100644 index 0000000..c6d4689 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/ChessUtil.java @@ -0,0 +1,164 @@ +package ru.fokinatorr.chess.impl; + +import lombok.experimental.UtilityClass; +import ru.fokinatorr.chess.impl.movestack.*; +import ru.fokinatorr.chess.impl.piece.*; +import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static java.util.Map.entry; +import static java.util.Map.ofEntries; + +/// Some utilities. +@UtilityClass +public class ChessUtil { + + /// Add the given element to the given collection, if it is not `null`. + /// @param collection the collection that may or may not be modified + /// @param element the element to add, if not `null` + /// @throws NullPointerException if collection is `null` + public void addIfNotNull(Collection collection, T element) { + addIfNotNull(collection, element, Function.identity()); + } + + /// Add the given element to the given collection, if it is not `null`. + /// @param collection the collection that may or may not be modified + /// @param element the element to add, if not `null` + /// @throws NullPointerException if collection is `null` + public void addSingletonListIfNotNull(Collection> collection, T element) { + addIfNotNull(collection, element, List::of); + } + + + /// Add the given element to the given collection, if it is not `null`. + /// @param collection the collection that may or may not be modified + /// @param element the element to add, if not `null` + /// @param finisher Some function that modifies the element, if it's not `null`. + /// @throws NullPointerException if collection or finisher is `null` + public void addIfNotNull(Collection collection, T element, Function finisher) { + if (element != null) { + collection.add(finisher.apply(element)); + } + } + + /// Returns whether the given move was a double move of a pawn of the given color to the given pos. + /// @param move the move to check + /// @param toPos the expected new position of the pawn + /// @param ofColor the expected color of the pawn + public boolean wasPawnDoubleMove(Move move, Position toPos, PieceColor ofColor) { + return move instanceof SimpleMove simpleMove + && simpleMove.getNewPos().equals(toPos) + && simpleMove.getMovedPiece() instanceof Pawn pawn + && pawn.getColor() == ofColor + && simpleMove.getOldPos().equals(toPos.down(2 * pawn.getForwardMovementFactor())) + && simpleMove.isWasNeverMoved(); + } + + /// Returns whether the given move was a pawn double move. + /// @param move the move to check + public boolean wasPawnDoubleMove(Move move) { + return move instanceof SimpleMove simpleMove + && simpleMove.getMovedPiece() instanceof Pawn pawn + && simpleMove.getOldPos().equals(simpleMove.getNewPos().down(2 * pawn.getForwardMovementFactor())) + && simpleMove.isWasNeverMoved(); + } + + /// Returns whether the given move was a capture. + /// @param move the move to check + public boolean wasCaptureMove(Move move) { + if (move instanceof SimpleMove simpleMove) { + return simpleMove.getCapturedPiece() != null; + } else if (move instanceof EnPassantMove) { + return true; + } else if (move instanceof PawnPromotionMove pawnPromotionMove) { + return pawnPromotionMove.getCapturedPiece() != null; + } + return false; + } + + /// Returns whether the given move is valid on the given board. + /// @throws IllegalArgumentException if the move was done + public boolean isValidMove(ChessBoardAndMoveStack chessBoardAndMoveStack, Move move) { + if (move == NoOpMove.INSTANCE) { + return chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving() == PieceColor.WHITE && chessBoardAndMoveStack.peekDone() == null && chessBoardAndMoveStack.peekUndone() == null; + } + if (move.isDone()) { + throw new IllegalArgumentException("The given move was done: " + move); + } + for (Position pos : chessBoardAndMoveStack.getPieces().keySet()) { + if (chessBoardAndMoveStack.getMoves(pos).stream().anyMatch(moveEntry -> moveEntry.move().equals(move))) { + return true; + } + } + return false; + } + + /// Suffixes the `base` string with `+` if it is check or with `#` if it is checkmate. + public String commonMoveSuffix(PieceColor movedColor, String base, ChessBoard chessBoard, MoveStack moveStack) { + if (chessBoard.isCheckmate(movedColor.reverse(), moveStack)) { + return base + "#"; + } else if (chessBoard.isCheck(movedColor.reverse())) { + return base + "+"; + } + return base; + } + + /// Parses the given UCI-format move to a `Move` object. Only to be used by chess engines. + /// @param s the move to parse + /// @param chessBoardAndMoveStack the chess board on which the move will be performed + public Move parseUCIMove(String s, ChessBoardAndMoveStack chessBoardAndMoveStack) { + if (s.equals("0000") || s.equals("(none)")) { + return NoOpMove.INSTANCE; + } + // Assuming that is a legal move + Position oldPos = Position.valueOf(s.substring(0, 2)), newPos = Position.valueOf(s.substring(2, 4)); + boolean isRight = newPos.getColumn().getIndex() - oldPos.getColumn().getIndex() > 0; + if (chessBoardAndMoveStack.isPieceOfType(oldPos, King.class) + && (newPos.equals(oldPos.left(2)) || newPos.equals(oldPos.right(2)))) { + if (isRight) { + return new CastlingMove(oldPos, Position.valueOf(Position.Column.H, oldPos.getRow()), true); + } else { + return new CastlingMove(oldPos, Position.valueOf(Position.Column.A, oldPos.getRow()), false); + } + } + if (chessBoardAndMoveStack.isPieceOfType(oldPos, Pawn.class)) { + Pawn pawn = (Pawn) chessBoardAndMoveStack.getPiece(oldPos); + if (chessBoardAndMoveStack.isEmpty(newPos) + && (newPos.equals(oldPos.move(pawn.getForwardMovementFactor(), 1)) + || newPos.equals(oldPos.move(pawn.getForwardMovementFactor(), -1)))) { + return new EnPassantMove(oldPos, isRight); + } + if (newPos.getRow().equals(pawn.getPromotionRow())) { + return new PawnPromotionMove(oldPos, newPos, pieceColor -> switch (s.charAt(4)) { + case 'q' -> new Queen(pieceColor); + case 'r' -> new Rook(pieceColor); + case 'n' -> new Knight(pieceColor); + case 'b' -> new Bishop(pieceColor); + default -> throw new IllegalArgumentException("Unexpected promotion piece: " + s.charAt(4)); + }); + } + } + return new SimpleMove(oldPos, newPos); + } + + // Pre-create functions to avoid reflection + private static final Map, Function> PIECE_CREATOR_BY_CLASS = ofEntries( + entry(Bishop.class, Bishop::new), + entry(King.class, King::new), + entry(Knight.class, Knight::new), + entry(Pawn.class, Pawn::new), + entry(Queen.class, Queen::new), + entry(Rook.class, Rook::new) + ); + + /// Returns a `Function` that creates a new instance of the given `Piece` class. This method exists to avoid reflection + /// when creating a piece by its class. + @SuppressWarnings("unchecked") + public

Function getPieceCreator(Class pieceClass) { + return (Function) PIECE_CREATOR_BY_CLASS.get(pieceClass); + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/MoveEntry.java b/src/main/java/ru/fokinatorr/chess/impl/MoveEntry.java new file mode 100644 index 0000000..a1805a8 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/MoveEntry.java @@ -0,0 +1,18 @@ +package ru.fokinatorr.chess.impl; + +import ru.fokinatorr.chess.impl.movestack.Move; + +import java.util.Objects; + +/// Some properties returned by {@link ChessBoard#getMoves ChessBoard.getMoves()} method. +/// @param destPos the destination pos of a piece + /// @param move the move to put in a move stack + /// @param isAttack whether this move can be a capture +public record MoveEntry(Position destPos, Move move, boolean isAttack) { + + /// @throws NullPointerException if any argument is `null` + public MoveEntry { + Objects.requireNonNull(destPos, "dest pos"); + Objects.requireNonNull(move, "move"); + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/MoveParser.java b/src/main/java/ru/fokinatorr/chess/impl/MoveParser.java new file mode 100644 index 0000000..f2f06cc --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/MoveParser.java @@ -0,0 +1,105 @@ +package ru.fokinatorr.chess.impl; + +import ru.fokinatorr.chess.impl.movestack.*; +import ru.fokinatorr.chess.impl.piece.*; +import ru.fokinatorr.chess.impl.Position.Column; +import ru.fokinatorr.chess.impl.Position.Row; + +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class MoveParser { + + public static Move parseMove(String moveNotation, ChessBoard chessBoard, MoveStack moveStack) { + // Remove check/checkmate suffixes + String notation = moveNotation.replaceAll("[+#]$", ""); + + // Castling + if (notation.equals("O-O")) { + // Assuming white kingside castling + return createCastlingMove(PieceColor.WHITE, true); + } else if (notation.equals("O-O-O")) { + // Assuming white queenside castling + return createCastlingMove(PieceColor.WHITE, false); + } + + // Pawn Promotion + Pattern promotionPattern = Pattern.compile("([a-h][1-8])=([QRBN])"); + Matcher promotionMatcher = promotionPattern.matcher(notation); + + if (promotionMatcher.matches()) { + String toPosStr = promotionMatcher.group(1); + String pieceTypeStr = promotionMatcher.group(2); + Position toPos = Position.valueOf(toPosStr); + PieceColor color = chessBoard.getPiece(toPos).getColor().reverse(); + + Function promoter = (pieceColor) -> switch (pieceTypeStr) { + case "Q" -> new Queen(pieceColor); + case "R" -> new Rook(pieceColor); + case "B" -> new Bishop(pieceColor); + case "N" -> new Knight(pieceColor); + default -> throw new IllegalArgumentException("Invalid promotion piece: " + pieceTypeStr); + }; + Position fromPos = toPos.up(color == PieceColor.WHITE ? -1 : 1); + return new PawnPromotionMove(fromPos, toPos, promoter); + } + + + // Piece doneMoves and pawn doneMoves + Pattern pieceMovePattern = Pattern.compile("([KQRBN]?)([a-h]?[1-8]?)?x?([a-h][1-8])"); + Matcher pieceMoveMatcher = pieceMovePattern.matcher(notation); + + if (pieceMoveMatcher.matches()) { + String pieceTypeStr = pieceMoveMatcher.group(1); + String disambiguation = pieceMoveMatcher.group(2); + String toPosStr = pieceMoveMatcher.group(3); + + Position toPos = Position.valueOf(toPosStr); + Class pieceType = parsePieceType(pieceTypeStr); + + // Find the piece on the board that can move to the target position + PieceColor color = null; + Position fromPos = null; + + for (Position pos : chessBoard.getPieces().keySet()) { + Piece piece = chessBoard.getPiece(pos); + if (piece != null && piece.getClass() == pieceType) { + if(chessBoard.getMoves(pos, moveStack).stream().anyMatch(move -> move.destPos().equals(toPos))){ + color = piece.getColor(); + fromPos = pos; + break; + } + } + } + + if (fromPos == null) { + throw new IllegalArgumentException("No piece found that can move to " + toPosStr); + } + + return new SimpleMove(fromPos, toPos); + } + + throw new IllegalArgumentException("Invalid move notation: " + moveNotation); + } + + private static CastlingMove createCastlingMove(PieceColor color, boolean isKingside) { + // Assuming standard castling positions. Adjust if needed for your setup. + int row = (color == PieceColor.WHITE) ? 0 : 7; + Position kingPos = Position.valueOf(Column.E, Row.valueOfIndex(row)); + Position rookPos = isKingside ? Position.valueOf(Column.H, Row.valueOfIndex(row)) : Position.valueOf(Column.A, Row.valueOfIndex(row)); + return new CastlingMove(kingPos, rookPos, isKingside); + } + + private static Class parsePieceType(String pieceTypeStr) { + switch (pieceTypeStr) { + case "K": return King.class; + case "Q": return Queen.class; + case "R": return Rook.class; + case "B": return Bishop.class; + case "N": return Knight.class; + case "": return Pawn.class; // Pawn move + default: throw new IllegalArgumentException("Invalid piece type: " + pieceTypeStr); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/fokinatorr/chess/impl/PieceColor.java b/src/main/java/ru/fokinatorr/chess/impl/PieceColor.java new file mode 100644 index 0000000..f9b7455 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/PieceColor.java @@ -0,0 +1,14 @@ +package ru.fokinatorr.chess.impl; + +/// A color of a piece in chess. +public enum PieceColor { + /// White color, at the bottom of the board at the beginning. They begin the game. + WHITE, + /// Black color, at the top of the board at the beginning. + BLACK; + + /// Returns the opposite of this color. + public PieceColor reverse() { + return this == WHITE ? BLACK : WHITE; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/PieceColorOrRandom.java b/src/main/java/ru/fokinatorr/chess/impl/PieceColorOrRandom.java new file mode 100644 index 0000000..07a0918 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/PieceColorOrRandom.java @@ -0,0 +1,25 @@ +package ru.fokinatorr.chess.impl; + +import lombok.AccessLevel; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; + +import java.util.random.RandomGenerator; + +/// A special enum to allow "random color" choice in GUIs, etc. +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public enum PieceColorOrRandom { + BLACK(PieceColor.BLACK), + WHITE(PieceColor.WHITE), + RANDOM(null); + + PieceColor pieceColor; + + /// Retrieve the backing [PieceColor], or a random one if `this == PieceColorOrRandom.RANDOM`. + @NonNull + public PieceColor getActualPieceColor(RandomGenerator random) { + return pieceColor == null ? PieceColor.values()[random.nextInt(2)] : pieceColor; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/Position.java b/src/main/java/ru/fokinatorr/chess/impl/Position.java new file mode 100644 index 0000000..a960007 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/Position.java @@ -0,0 +1,275 @@ +package ru.fokinatorr.chess.impl; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; +import lombok.experimental.FieldDefaults; +import ru.fokinatorr.chess.misc.Pair; + +import java.util.HashMap; +import java.util.Map; + +import java.util.Objects; +import java.util.regex.Pattern; + +import static java.util.Map.entry; +import static java.util.Map.ofEntries; + +/// A position on a chess board, consisting of a letter `a`-`h` that identifies the column and a number `1`-`8` that +/// identifies the row, e.g. "`e4`". +///

+/// In this class there's a {@link Position#POSITION_PATTERN pattern} describing the rules above. +@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) +@Data +public final class Position { + /// A {@link java.util.regex RegExp} [Pattern] matching all possible chess board positions. + public static final Pattern POSITION_PATTERN = Pattern.compile("^[a-h][1-8]$"); + /// Cache the positions to save more memory. + private static final Map, Position> POSITION_CACHE = HashMap.newHashMap(64); + + Column column; + Row row; + + /// Creates a new `Position` with the given column and row. + /// Use the {@link #valueOf static factory} to get an instance of `Position`. + /// @param column the column + /// @param row the row + private Position(Column column, Row row) { + // all sanity checks are done in factory methods + this.column = column; + this.row = row; + } + + /// Creates (or gets an existing one) and returns an instance of `Position` with the given + /// column and row. + /// @param column the column + /// @param row the row + /// @throws NullPointerException if any of the arguments is `null` + @JsonCreator + public static Position valueOf(@JsonProperty("column") final Column column, @JsonProperty("row") final Row row) { + Objects.requireNonNull(column, "column"); + Objects.requireNonNull(row, "row"); + + return POSITION_CACHE.computeIfAbsent(new Pair<>(column, row), _ -> new Position(column, row)); + } + + /// Creates (or gets an existing one) and returns an instance of `Position` with the given + /// column and row names. + /// @param column the column name + /// @param row the row name + /// @throws IllegalArgumentException if any argument is out of bounds + public static Position valueOf(final char column, final int row) { + return valueOf(Column.valueOf(column), Row.valueOfNumber(row)); + } + + /// Creates (or gets an existing one) and returns an instance of `Position` with the given + /// column and row indexes. + /// @param column the column index + /// @param row the row index + /// @throws IllegalArgumentException if any argument is out of bounds + public static Position valueOf(final int column, final int row) { + return valueOf(Column.valueOf(column), Row.valueOfIndex(row)); + } + + /// Creates (or gets an existing one) and returns an instance of `Position` with the given + /// string representation. + /// @param pos the textual representation of the position + /// @throws NullPointerException if `pos` is `null` + /// @throws IllegalArgumentException if the given string is invalid + public static Position valueOf(final String pos) { + if (!POSITION_PATTERN.matcher(Objects.requireNonNull(pos, "pos")).matches()) { + throw new IllegalArgumentException("Unexpected value: " + pos); + } + return valueOf(Column.valueOf(pos.charAt(0)), Row.valueOfNumber(pos.charAt(1) - '0')); + } + + // moving position + + /// Move this position up the given amount of times. If the resulting position is out of bounds, + /// `null` is returned. + /// @param times how much times to move up + public Position up(int times) { + int newRowIndex = row.index + times; + return Row.BY_INDEX.containsKey(newRowIndex) ? valueOf(column.index, newRowIndex) : null; + } + + /// Move this position down the given amount of times. If the resulting position is out of bounds, + /// `null` is returned. + /// @param times how much times to move down + public Position down(int times) { + return up(-times); + } + + /// Move this position right the given amount of times. If the resulting position is out of bounds, + /// `null` is returned. + /// @param times how much times to move right + public Position left(int times) { + return right(-times); + } + + /// Move this position right the given amount of times. If the resulting position is out of bounds, + /// `null` is returned. + /// @param times how much times to move right + public Position right(int times) { + int newColumnIndex = column.index + times; + return Column.BY_INDEX.containsKey(newColumnIndex) ? valueOf(newColumnIndex, row.index) : null; + } + + // free move + + /// Move this position up and right the given amount of times. If the resulting position is out of bounds, + /// `null` is returned. + /// @param up how much times to move up + /// @param right how much times to move right + public Position move(int up, int right) { + Position pos = up(up); + return pos == null ? null : pos.right(right); + } + + // I think diagonal moving will be helpful too + + /// Move this position diagonally up and right the given amount of times. If the resulting position is out of bounds, + /// `null` is returned. + /// @param times how much times to move up and right + public Position upRight(int times) { + return move(times, times); + } + + /// Move this position diagonally up and left the given amount of times. If the resulting position is out of bounds, + /// `null` is returned. + /// @param times how much times to move up and left + public Position upLeft(int times) { + return move(times, -times); + } + + /// Move this position diagonally down and right the given amount of times. If the resulting position is out of bounds, + /// `null` is returned. + /// @param times how much times to move down and right + public Position downRight(int times) { + return move(-times, times); + } + + /// Move this position diagonally down and left the given amount of times. If the resulting position is out of bounds, + /// `null` is returned. + /// @param times how much times to move down and left + public Position downLeft(int times) { + return move(-times, -times); + } + + @Override + public String toString() { + return column.character + String.valueOf(row.getNumber()); + } + + + /// An enumeration class identifying a column on a chess board. + @Getter + @RequiredArgsConstructor + @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) + public enum Column { + A('a', 0), + B('b', 1), + C('c', 2), + D('d', 3), + E('e', 4), + F('f', 5), + G('g', 6), + H('h', 7); + + private static Map BY_CHARACTER; + private static Map BY_INDEX; + + char character; + int index; + + public static Column valueOf(char character) { + if (BY_CHARACTER == null) { + BY_CHARACTER = ofEntries( + entry('a', A), + entry('b', B), + entry('c', C), + entry('d', D), + entry('e', E), + entry('f', F), + entry('g', G), + entry('h', H) + ); + } + Column result = BY_CHARACTER.get(character); + if (result == null) { + throw new IllegalArgumentException("Unexpected value: '" + character + "'"); + } + return result; + } + + public static Column valueOf(int index) { + if (BY_INDEX == null) { + BY_INDEX = ofEntries( + entry(0, A), + entry(1, B), + entry(2, C), + entry(3, D), + entry(4, E), + entry(5, F), + entry(6, G), + entry(7, H) + ); + } + Column result = BY_INDEX.get(index); + if (result == null) { + throw new IllegalArgumentException("Unexpected value: " + index); + } + return result; + } + } + + /// An enumeration class identifying a row on a chess board. + @Getter + @RequiredArgsConstructor + @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) + public enum Row { + ONE(0), + TWO(1), + THREE(2), + FOUR(3), + FIVE(4), + SIX(5), + SEVEN(6), + EIGHT(7); + + private static Map BY_INDEX; + + int index; + + /** + * {@return the number representation of a row} + */ + public int getNumber() { + return index + 1; + } + + public static Row valueOfNumber(int number) { + return valueOfIndex(number - 1); + } + + public static Row valueOfIndex(int index) { + if (BY_INDEX == null) { + BY_INDEX = ofEntries( + entry(0, ONE), + entry(1, TWO), + entry(2, THREE), + entry(3, FOUR), + entry(4, FIVE), + entry(5, SIX), + entry(6, SEVEN), + entry(7, EIGHT) + ); + } + Row result = BY_INDEX.get(index); + if (result == null) { + throw new IllegalArgumentException("Unexpected value: " + index); + } + return result; + } + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/misc/ChessBoardAndMoveStack.java b/src/main/java/ru/fokinatorr/chess/impl/misc/ChessBoardAndMoveStack.java new file mode 100644 index 0000000..04cf923 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/misc/ChessBoardAndMoveStack.java @@ -0,0 +1,285 @@ +package ru.fokinatorr.chess.impl.misc; + +import lombok.*; +import lombok.experimental.FieldDefaults; +import lombok.experimental.UtilityClass; +import ru.fokinatorr.chess.impl.*; +import ru.fokinatorr.chess.impl.movestack.Move; +import ru.fokinatorr.chess.impl.movestack.MoveStack; +import ru.fokinatorr.chess.impl.movestack.SimpleMove; +import ru.fokinatorr.chess.impl.piece.*; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; + +/// A pair of [ChessBoard] and [MoveStack]. +@Getter +@EqualsAndHashCode +@ToString +@RequiredArgsConstructor +@FieldDefaults(makeFinal = true, level = AccessLevel.PROTECTED) +public class ChessBoardAndMoveStack implements Serializable, Cloneable { // not record to allow subclassing + + @Serial + private static final long serialVersionUID = -5529146297013520402L; + + @NonNull + ChessBoard chessBoard; + @NonNull + MoveStack moveStack; + + /// Constructs an empty `ChessBoardAndMoveStack`. + public ChessBoardAndMoveStack() { + this(new ChessBoard(), new MoveStack()); + } + + /// Constructs a `ChessBoardAndMoveStack` with the given starting position chess board. It is cloned. + public ChessBoardAndMoveStack(ChessBoard startPos) { + this(startPos.clone(), new MoveStack()); + } + + /// Constructs a `ChessBoardAndMoveStack`, in which the starting position is {@link ChessBoard#startPosition() default} + /// and then the doneMoves from the given stack are performed on it. + public ChessBoardAndMoveStack(MoveStack moveStack) { + this(ChessBoard.startPosition(), new MoveStack()); + for (Move move : moveStack.getDoneMoves()) { + perform(move); + } + } + + /// Returns a `ChessBoardAndMoveStack`, in which the starting position is {@link ChessBoard#startPosition() default}. + public static ChessBoardAndMoveStack startPosition() { + return new ChessBoardAndMoveStack(ChessBoard.startPosition()); + } + + /// @see MoveStack#perform(Move, ChessBoard) + public void perform(Move move) { + moveStack.perform(move, chessBoard); + } + + /// @see MoveStack#undo(ChessBoard) + public Move undo() { + return moveStack.undo(chessBoard); + } + + /// @see MoveStack#redo(ChessBoard) + public Move redo() { + return moveStack.redo(chessBoard); + } + + /// @see MoveStack#peekDone() + public Move peekDone() { + return moveStack.peekDone(); + } + + /// @see MoveStack#peekUndone() + public Move peekUndone() { + return moveStack.peekUndone(); + } + + /// Sets the pawn promoter for the chess board. + /// @see ChessBoard#setPawnPromoter(Function) + public void setPawnPromoter(Function pawnPromoter) { + chessBoard.setPawnPromoter(pawnPromoter); + } + + /// Reverses the currently moving player. + /// @see PieceColor#reverse() + /// @see ChessBoard#reverseCurrentlyMoving() + public void reverseCurrentlyMoving() { + chessBoard.reverseCurrentlyMoving(); + } + + /// @see ChessBoard#getPiece(Position) + public Piece getPiece(Position pos) { + return chessBoard.getPiece(pos); + } + + /// @see ChessBoard#getPieces(Position.Column) + public List getPieces(Position.Column column) { + return chessBoard.getPieces(column); + } + + /// @see ChessBoard#getPieces(Position.Row) + public List getPieces(Position.Row row) { + return chessBoard.getPieces(row); + } + + /// @see ChessBoard#getPieces() + public Map getPieces() { + return chessBoard.getPieces(); + } + + /// @see ChessBoard#setPiece(Position, Piece) + public Piece setPiece(Position pos, Piece newPiece) { + return chessBoard.setPiece(pos, newPiece); + } + + /// @see ChessBoard#isPieceOfColor(Position, PieceColor) + public boolean isPieceOfColor(Position pos, PieceColor color) { + return chessBoard.isPieceOfColor(pos, color); + } + + /// @see ChessBoard#isPieceOfType(Position, Class) + public boolean isPieceOfType(Position pos, Class type) { + return chessBoard.isPieceOfType(pos, type); + } + + /// @see ChessBoard#isPieceOfColorAndType(Position, PieceColor, Class) + public boolean isPieceOfColorAndType(Position pos, PieceColor color, Class type) { + return chessBoard.isPieceOfColorAndType(pos, color, type); + } + + /// @see ChessBoard#isPresent(Position) + public boolean isPresent(Position pos) { + return chessBoard.isPresent(pos); + } + + /// @see ChessBoard#isEmpty(Position) + public boolean isEmpty(Position pos) { + return chessBoard.isEmpty(pos); + } + + /// @see ChessBoard#getMoves(Position, MoveStack) + public Collection getMoves(Position pos) { + return chessBoard.getMoves(pos, moveStack); + } + + /// @see ChessBoard#isPositionAttacked(Position, PieceColor) + public boolean isPositionAttacked(Position givenPos, PieceColor color) { + return chessBoard.isPositionAttacked(givenPos, color); + } + + /// @see ChessBoard#isCheck(PieceColor) + public boolean isCheck(PieceColor kingColor) { + return chessBoard.isCheck(kingColor); + } + + /// @see ChessBoard#isCheckmate(PieceColor, MoveStack) + public boolean isCheckmate(PieceColor color) { + return chessBoard.isCheckmate(color, moveStack); + } + + /// @see ChessBoard#isStalemate(PieceColor, MoveStack) + public boolean isStalemate(PieceColor color) { + return chessBoard.isStalemate(color, moveStack); + } + + /// Converts this `ChessBoardAndMoveStack` to Forsyth–Edwards Notation (FEN) format. Useful for `ChessEngine` implementations. + public String toFEN() { + StringBuilder sb = new StringBuilder(); + // write down pieces + for (int i = Position.Row.values().length - 1; i >= 0; i--) { // reverse order of rows + Position.Row row = Position.Row.values()[i]; + int emptyCount = 0; + for (Position.Column column : Position.Column.values()) { + Piece piece = getPiece(Position.valueOf(column, row)); + if (piece != null) { + if (emptyCount != 0) { + sb.append(emptyCount); + } + emptyCount = 0; + char pieceFEN = switch (piece) { + case Bishop _ -> 'B'; + case King _ -> 'K'; + case Knight _ -> 'N'; + case Pawn _ -> 'P'; + case Queen _ -> 'Q'; + case Rook _ -> 'R'; + }; + sb.append(piece.getColor() == PieceColor.BLACK ? Character.toLowerCase(pieceFEN) : pieceFEN); + } else { + emptyCount++; + } + } + if (emptyCount != 0) { + sb.append(emptyCount); + } + sb.append('/'); + } + sb.deleteCharAt(sb.length() - 1) // remove last '/' + .append(' ') + .append(chessBoard.getCurrentlyMoving().name().toLowerCase(Locale.ENGLISH).charAt(0)) // currently moving color + .append(' '); + + boolean noCastling = true; + if (FENHelper.canCastle(chessBoard, PieceColor.WHITE, FENHelper.WHITE_KING_POS, FENHelper.WHITE_ROOK_KSIDE_POS)) { + sb.append('K'); + noCastling = false; + } + if (FENHelper.canCastle(chessBoard, PieceColor.WHITE, FENHelper.WHITE_KING_POS, FENHelper.WHITE_ROOK_QSIDE_POS)) { + sb.append('Q'); + noCastling = false; + } + if (FENHelper.canCastle(chessBoard, PieceColor.BLACK, FENHelper.BLACK_KING_POS, FENHelper.BLACK_ROOK_KSIDE_POS)) { + sb.append('k'); + noCastling = false; + } + if (FENHelper.canCastle(chessBoard, PieceColor.BLACK, FENHelper.BLACK_KING_POS, FENHelper.BLACK_ROOK_QSIDE_POS)) { + sb.append('q'); + noCastling = false; + } + if (noCastling) { + sb.append('-'); + } + sb.append(' '); + + if (ChessUtil.wasPawnDoubleMove(peekDone())) { // implicit null check in Util.wasPawnDoubleMove() + SimpleMove simpleMove = (SimpleMove) peekDone(); + sb.append(simpleMove.getNewPos().down(((Pawn) simpleMove.getMovedPiece()).getForwardMovementFactor())); + } else { + sb.append('-'); + } + sb.append(' '); + + int fiftyMovesClock = 0; + double moveCount = 1; + + for (Move move : moveStack.getDoneMoves()) { + if (move instanceof SimpleMove simpleMove && simpleMove.getMovedPiece() instanceof Pawn || ChessUtil.wasCaptureMove(move)) { + fiftyMovesClock = 0; + } else { + fiftyMovesClock++; + } + // Since moveStack.getDoneMoves() returns Collection, we can't find index of any item + moveCount += 0.5; + } + sb.append(fiftyMovesClock) + .append(' ') + .append((int) Math.floor(moveCount)); // cast to int to prevent ".0" suffix + return sb.toString(); + } + + /// Creates and returns a copy of this `ChessBoardAndMoveStack`. + @Override + @SuppressWarnings("MethodDoesntCallSuperMethod") + public ChessBoardAndMoveStack clone() { + return new ChessBoardAndMoveStack(chessBoard.clone(), moveStack.clone()); + } +} + +@UtilityClass +class FENHelper { + + static final Position WHITE_KING_POS = Position.valueOf(Position.Column.E, Position.Row.ONE), + WHITE_ROOK_QSIDE_POS = Position.valueOf(Position.Column.A, Position.Row.ONE), + WHITE_ROOK_KSIDE_POS = Position.valueOf(Position.Column.H, Position.Row.ONE), + BLACK_KING_POS = Position.valueOf(Position.Column.E, Position.Row.EIGHT), + BLACK_ROOK_QSIDE_POS = Position.valueOf(Position.Column.A, Position.Row.EIGHT), + BLACK_ROOK_KSIDE_POS = Position.valueOf(Position.Column.H, Position.Row.EIGHT); + + boolean isPieceOfColorAndTypeAndNeverMoved(ChessBoard chessBoard, Position pos, PieceColor color, Class type) { + return chessBoard.isPieceOfColorAndType(pos, color, type) // if piece == null, returns false + && chessBoard.getPiece(pos).isNeverMoved(); + } + + boolean canCastle(ChessBoard chessBoard, PieceColor color, Position kingPos, Position rookPos) { + return isPieceOfColorAndTypeAndNeverMoved(chessBoard, kingPos, color, King.class) + && isPieceOfColorAndTypeAndNeverMoved(chessBoard, rookPos, color, Rook.class); + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/movestack/CastlingMove.java b/src/main/java/ru/fokinatorr/chess/impl/movestack/CastlingMove.java new file mode 100644 index 0000000..1d7b4bd --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/movestack/CastlingMove.java @@ -0,0 +1,116 @@ +package ru.fokinatorr.chess.impl.movestack; + +import lombok.*; +import lombok.experimental.FieldDefaults; +import ru.fokinatorr.chess.impl.ChessBoard; +import ru.fokinatorr.chess.impl.ChessUtil; +import ru.fokinatorr.chess.impl.Position; +import ru.fokinatorr.chess.impl.piece.King; +import ru.fokinatorr.chess.impl.piece.Piece; +import ru.fokinatorr.chess.impl.piece.Rook; + +/// A special move, where a king doneMoves two fields horizontally and a rook stands on the position which the king "jumped" over. +/// @see "Castling" on Wikipedia +@Getter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString +@FieldDefaults(level = AccessLevel.PRIVATE) +public non-sealed class CastlingMove implements Move { + + // explicit constructor because kingNewPos & rookNewPos are final and for some reason + // the compiler doesn't allow to init them in the field definitions D: + /// Creates a new `CastlingMove`. + /// @param kingOldPos The king's initial position (must be e1 or e8) + /// @param rookOldPos The rook's initial position (must be a1, a8, h1 or h8) + /// @param kingside whether to castle to the right + public CastlingMove(Position kingOldPos, Position rookOldPos, boolean kingside) { + this.kingOldPos = kingOldPos; + this.rookOldPos = rookOldPos; + this.kingside = kingside; + kingNewPos = kingOldPos.right(kingside ? 2 : -2); + rookNewPos = kingOldPos.right(kingside ? 1 : -1); + } + + final Position kingOldPos; + final Position rookOldPos; + final boolean kingside; + King king; + Rook rook; + final Position kingNewPos; + final Position rookNewPos; + @Getter(AccessLevel.NONE) + String cacheNotation; + boolean done; + + + /// {@inheritDoc} + /// @throws IllegalStateException {@inheritDoc} + @Override + public void perform(ChessBoard chessBoard, MoveStack moveStack) { + Piece piece1 = chessBoard.setPiece(kingOldPos, null); + if (!(piece1 instanceof King kingPiece)) { + throw new IllegalStateException("The piece on " + kingOldPos + " on " + chessBoard + " is not a King"); + } else { + Piece piece2 = chessBoard.setPiece(rookOldPos, null); + if (!(piece2 instanceof Rook rookPiece)) { + throw new IllegalStateException("The piece on " + rookOldPos + " on " + chessBoard + " is not a Rook"); + } else if (rookPiece.getColor() != kingPiece.getColor()) { + throw new IllegalStateException(rookPiece + " and " + kingPiece + " are not the same color"); + } else { + king = kingPiece; + rook = rookPiece; + chessBoard.setPiece(kingNewPos, kingPiece); + chessBoard.setPiece(rookNewPos, rookPiece); + done = true; + } + } + } + + /// {@inheritDoc} + /// @throws IllegalStateException {@inheritDoc} + @Override + public void undo(ChessBoard chessBoard) { + if (chessBoard.setPiece(kingNewPos, null) == null) { + throw new IllegalStateException("No piece to move from " + kingNewPos + " on " + chessBoard); + } + if (chessBoard.setPiece(rookNewPos, null) == null) { + throw new IllegalStateException("No piece to move from " + rookNewPos + " on " + chessBoard); + } + chessBoard.setPiece(kingOldPos, king); + chessBoard.setPiece(rookOldPos, rook); + king = null; + rook = null; + cacheNotation = null; + done = false; + } + + /// {@inheritDoc} + /// @throws IllegalStateException {@inheritDoc} + @Override + public String getNotationRepresentation(ChessBoard afterMoveChessBoard, MoveStack afterMoveMoveStack) { + if (!done) { + throw new IllegalStateException("This move wasn't done yet"); + } + if (cacheNotation != null) { + return cacheNotation; + } + return cacheNotation = ChessUtil.commonMoveSuffix(king.getColor(), kingside ? "O-O" : "O-O-O", afterMoveChessBoard, afterMoveMoveStack); + } + + /// {@inheritDoc} + @Override + public String toUCI() { + return kingOldPos.toString() + kingNewPos.toString(); + } + + /// {@inheritDoc} + @Override + public CastlingMove deepCopy() { + CastlingMove copy = new CastlingMove(kingOldPos, rookOldPos, kingside); + copy.king = this.king == null ? null : (King) this.king.clone(); + copy.rook = this.rook == null ? null : (Rook) this.rook.clone(); + copy.cacheNotation = this.cacheNotation; + copy.done = this.done; + return copy; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/movestack/EnPassantMove.java b/src/main/java/ru/fokinatorr/chess/impl/movestack/EnPassantMove.java new file mode 100644 index 0000000..de9a85b --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/movestack/EnPassantMove.java @@ -0,0 +1,123 @@ +package ru.fokinatorr.chess.impl.movestack; + +import lombok.*; +import lombok.experimental.FieldDefaults; +import ru.fokinatorr.chess.impl.ChessBoard; +import ru.fokinatorr.chess.impl.ChessUtil; +import ru.fokinatorr.chess.impl.Position; +import ru.fokinatorr.chess.impl.piece.Pawn; +import ru.fokinatorr.chess.impl.piece.Piece; + +import java.util.Locale; +import java.util.Objects; + +/// A special move, so-called "en passant capture" where a pawn can capture another pawn, "passing" it. +/// @see "En passant" on Wikipedia +@Getter +@RequiredArgsConstructor +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString +@FieldDefaults(level = AccessLevel.PRIVATE) +public non-sealed class EnPassantMove implements Move { + // Yes, it's that one move that requires VERY specific conditions to be legal. It must even consider the previous move! :O + + @EqualsAndHashCode.Include + final Position oldPos; + @EqualsAndHashCode.Include + final boolean isRight; + Position newPos; + Pawn movedPiece; + Position capturePos; + Pawn capturedPiece; + @Getter(AccessLevel.NONE) + String cacheNotation; + boolean done; + + /// {@inheritDoc} + /// @throws IllegalStateException {@inheritDoc} + /// @throws NullPointerException if a piece is moved out of range + @Override + public void perform(ChessBoard chessBoard, MoveStack moveStack) { + Piece piece = chessBoard.setPiece(oldPos, null); + if (piece == null) { + throw new IllegalStateException("No piece to move from " + oldPos + " on " + chessBoard); + } else if (!(piece instanceof Pawn pawn)) { + throw new IllegalStateException("The piece on " + oldPos + " on " + chessBoard + " is not a Pawn, but a " + piece.getClass().getSimpleName()); + } else if (pawn.getEnPassantRow() != oldPos.getRow()) { + throw new IllegalStateException("A " + pawn.getColor().toString().toLowerCase(Locale.ENGLISH) + " Pawn can't perform an en passant from row " + oldPos.getRow().getNumber()); + } else { + Position capture = Objects.requireNonNull(oldPos.right(isRight ? 1 : -1), "Can't move the Pawn from " + oldPos + " outside the board"); + Position target = Objects.requireNonNull(capture.up(pawn.getForwardMovementFactor()), "Can't move the Pawn from " + oldPos + " outside the board"); + Piece targetPiece = chessBoard.getPiece(capture); + if (!(targetPiece instanceof Pawn targetPawn) || targetPawn.getColor() == pawn.getColor()) { + throw new IllegalStateException("En passant can only capture a pawn of different color"); + } else { + if (moveStack == null || ChessUtil.wasPawnDoubleMove(moveStack.peekDone(), capture, targetPawn.getColor())) { + movedPiece = pawn; + capturedPiece = targetPawn; + capturePos = capture; + newPos = target; + chessBoard.setPiece(capture, null); + chessBoard.setPiece(target, pawn); + done = true; + } else { + throw new IllegalStateException("The previous move must be a double-move of a pawn of different color"); + } + } + } + } + + /// {@inheritDoc} + /// @throws IllegalStateException {@inheritDoc} + @Override + public void undo(ChessBoard chessBoard) { + if (chessBoard.setPiece(newPos, null) == null) { + throw new IllegalStateException("No piece to move from " + newPos + " on " + chessBoard); + } + chessBoard.setPiece(oldPos, movedPiece); + chessBoard.setPiece(capturePos, capturedPiece); + // cache the newPos for UCI + movedPiece = null; + capturedPiece = null; + capturePos = null; + done = false; + } + + /// {@inheritDoc} + /// @throws IllegalStateException {@inheritDoc} + @Override + public String getNotationRepresentation(ChessBoard afterMoveChessBoard, MoveStack afterMoveMoveStack) { + if (!done) { + throw new IllegalStateException("This move wasn't done yet"); + } + if (cacheNotation != null) { + return cacheNotation; + } + return cacheNotation = ChessUtil.commonMoveSuffix( + movedPiece.getColor(), + oldPos.getColumn().getCharacter() + "x" + newPos + " e.p.", + afterMoveChessBoard, + afterMoveMoveStack + ); + } + + /// {@inheritDoc} + @Override + public String toUCI() { + return oldPos.toString() + newPos.toString(); + } + + + /// {@inheritDoc} + @Override + public EnPassantMove deepCopy() { + EnPassantMove copy = new EnPassantMove(oldPos, isRight); + copy.newPos = this.newPos; + copy.movedPiece = this.movedPiece == null ? null : (Pawn) this.movedPiece.clone(); + copy.capturePos = this.capturePos; + copy.capturedPiece = this.capturedPiece == null ? null : (Pawn) this.capturedPiece.clone(); + copy.cacheNotation = this.cacheNotation; + copy.done = this.done; + return copy; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/movestack/Move.java b/src/main/java/ru/fokinatorr/chess/impl/movestack/Move.java new file mode 100644 index 0000000..b5ff4ac --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/movestack/Move.java @@ -0,0 +1,37 @@ +package ru.fokinatorr.chess.impl.movestack; + +import ru.fokinatorr.chess.impl.ChessBoard; + +/// Defines a move performed on a chessboard, someway modifying it. It can be undone. +///

+/// It is not recommended to perform and undo this move on different boards. +public sealed interface Move extends java.io.Serializable permits CastlingMove, EnPassantMove, NoOpMove, PawnPromotionMove, SimpleMove { + /// Perform this move on the given board. + /// @param moveStack may be required if the move needs to know the previous move. Only yet required by [EnPassantMove]. + /// @throws IllegalStateException if the move couldn't be performed on the given board + void perform(ChessBoard chessBoard, MoveStack moveStack); + + /// Undo this move on the given board. + /// @throws IllegalStateException if the move couldn't be undone on the given board + void undo(ChessBoard chessBoard); + + /// Determine whether this move was done on some board. + boolean isDone(); + + /// Returns how this move would be represented in the chess notation, + /// e.g. `Nc3` if a knight moved to `c3`. Only can be executed if this move + /// was {@link #isDone() done}. + /// @throws IllegalStateException if this move wasn't done yet. + String getNotationRepresentation(ChessBoard afterMoveChessBoard, MoveStack afterMoveMoveStack); + + /// Returns this move in the Universal Chess Interface (UCI) format. + String toUCI(); + + /// Creates and returns a deep copy of this move. This method exists instead of clone() because enums do not allow cloning. + Move deepCopy(); + + /// Returns whether this move is equal to the given object. Two moves are equal if and only if + /// they change the same board the same way. + @Override + boolean equals(Object obj); +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/movestack/MoveStack.java b/src/main/java/ru/fokinatorr/chess/impl/movestack/MoveStack.java new file mode 100644 index 0000000..9baea0d --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/movestack/MoveStack.java @@ -0,0 +1,88 @@ +package ru.fokinatorr.chess.impl.movestack; + + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.experimental.FieldDefaults; +import ru.fokinatorr.chess.impl.ChessBoard; + +import java.util.ArrayDeque; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; + +/// A stack containing the moves that were done on some board. +/// It has two stacks: done moves and undone moves, so redoing moves is possible. +@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) +public class MoveStack implements java.io.Serializable, Cloneable { + + @java.io.Serial + private static final long serialVersionUID = 1698600303738920117L; + + Deque doneMoves = new ArrayDeque<>(); + Deque undoneMoves = new ArrayDeque<>(); + + /// Performs the given move on the given board, puts it into the done moves stack and clears the undone moves stack. + /// @throws IllegalStateException if the move couldn't be performed on the given board + public void perform(Move move, ChessBoard chessBoard) { + move.perform(chessBoard, this); + doneMoves.offerLast(move); + undoneMoves.clear(); + } + + /// Undoes the last move in done moves stack on the given board, puts it into the undone moves stack, removes it from the done moves stack and returns it. + /// If there's no move to undo, `null` is returned. + /// @throws IllegalStateException if the move couldn't be undone on the given board + public Move undo(ChessBoard chessBoard) { + Move undo = doneMoves.pollLast(); + if (undo != null) { + undo.undo(chessBoard); + undoneMoves.offerLast(undo); + } + return undo; + } + + /// Redoes the last move in undone moves stack on the given board, puts it into the done moves stack, removes it from the undone moves stack and returns it. + /// If there's no move to redo, `null` is returned. + /// @throws IllegalStateException if the move couldn't be performed on the given board + public Move redo(ChessBoard chessBoard) { + Move redo = undoneMoves.pollLast(); + if (redo != null) { + redo.perform(chessBoard, this); + doneMoves.offerLast(redo); + } + return redo; + } + + /// Retrieves, but does not remove, the last move in the done moves stack, + /// or returns `null` if the stack is empty. + public Move peekDone() { + return doneMoves.peekLast(); + } + + /// Retrieves, but does not remove, the last move in the undone moves stack, + /// or returns `null` if the stack is empty. + public Move peekUndone() { + return undoneMoves.peekLast(); + } + + /// Returns an unmodifiable view of the done moves stack. This method exists to allow restoring the chess board by a stack of moves. + public Collection getDoneMoves() { + return Collections.unmodifiableCollection(doneMoves); + } + + /// Returns an unmodifiable view of the undone moves stack. This method exists to allow restoring the chess board by a stack of moves. + public Collection getUndoneMoves() { + return Collections.unmodifiableCollection(undoneMoves); + } + + /// Creates and returns a copy of this `MoveStack`. + @Override + @SuppressWarnings("MethodDoesntCallSuperMethod") + public MoveStack clone() { + MoveStack newStack = new MoveStack(); + newStack.doneMoves.addAll(doneMoves.stream().map(Move::deepCopy).toList()); + newStack.undoneMoves.addAll(undoneMoves.stream().map(Move::deepCopy).toList()); + return newStack; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/movestack/NoOpMove.java b/src/main/java/ru/fokinatorr/chess/impl/movestack/NoOpMove.java new file mode 100644 index 0000000..153ce88 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/movestack/NoOpMove.java @@ -0,0 +1,47 @@ +package ru.fokinatorr.chess.impl.movestack; + +import ru.fokinatorr.chess.impl.ChessBoard; + +/// A move that does nothing. This move is required if black starts in a custom position, then first white's move is a `NoOpMove`. +public enum NoOpMove implements Move { + + /// Sole instance. + INSTANCE; + + /// {@inheritDoc} + /// As stated in the class's documentation, this move does nothing. + @Override + public void perform(ChessBoard chessBoard, MoveStack moveStack) { + } + + /// {@inheritDoc} + /// As stated in the class's documentation, this move does nothing, so there's nothing to undo. + @Override + public void undo(ChessBoard chessBoard) { + } + + /// Returns how this move would be represented in chess notation. Unlike other implementations, this method never throws any exceptions. + @Override + public String getNotationRepresentation(ChessBoard afterMoveChessBoard, MoveStack afterMoveMoveStack) { + return "..."; + } + + ///{@inheritDoc} + @Override + public String toUCI() { + return "0000"; + } + + /// {@inheritDoc} + @Override + public NoOpMove deepCopy() { + return INSTANCE; // enums can't clone + } + + /// {@inheritDoc} + /// This method always returns `true`, but that doesn't really matter since this is never checked. + @Override + public boolean isDone() { + return true; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/movestack/PawnPromotionMove.java b/src/main/java/ru/fokinatorr/chess/impl/movestack/PawnPromotionMove.java new file mode 100644 index 0000000..fa77287 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/movestack/PawnPromotionMove.java @@ -0,0 +1,129 @@ +package ru.fokinatorr.chess.impl.movestack; + +import lombok.*; +import lombok.experimental.FieldDefaults; +import ru.fokinatorr.chess.impl.ChessBoard; +import ru.fokinatorr.chess.impl.ChessUtil; +import ru.fokinatorr.chess.impl.PieceColor; +import ru.fokinatorr.chess.impl.Position; +import ru.fokinatorr.chess.impl.piece.King; +import ru.fokinatorr.chess.impl.piece.Pawn; +import ru.fokinatorr.chess.impl.piece.Piece; + +import java.util.Locale; +import java.util.Objects; +import java.util.function.Function; + +/// A special move, where a pawn doneMoves to the edge of a board and transforms into some other piece (any except king). +/// @see "Pawn promotion" on Wikipedia +@Getter +@RequiredArgsConstructor +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString +@FieldDefaults(level = AccessLevel.PRIVATE) +public non-sealed class PawnPromotionMove implements Move { + + @EqualsAndHashCode.Include + final Position oldPos; + @EqualsAndHashCode.Include + final Position newPos; + @Setter + @NonNull + Function pawnPromoter; + // OR + Class newPieceType; + Pawn movedPiece; + Piece promotedToPiece; + Piece capturedPiece; + @Getter(AccessLevel.NONE) + String cacheNotation; + boolean done; + + ///{@inheritDoc} + /// @throws IllegalStateException {@inheritDoc} + /// @throws IllegalArgumentException if the piece to promote to is invalid + /// @throws NullPointerException if the piece to promote to is `null` + @Override + public void perform(ChessBoard chessBoard, MoveStack moveStack) { + Piece piece = chessBoard.setPiece(oldPos, null); + if (piece == null) { + throw new IllegalStateException("No piece to move from " + oldPos + " on " + chessBoard); + } else if (!(piece instanceof Pawn pawn)) { + throw new IllegalStateException("The piece on " + oldPos + " on " + chessBoard + " is not a Pawn, but a " + piece.getClass().getSimpleName()); + } else { + movedPiece = pawn; + if (newPieceType != null) { // the move is redone + pawnPromoter = ChessUtil.getPieceCreator(newPieceType); + } + Piece newPiece = Objects.requireNonNull(pawnPromoter.apply(pawn.getColor()), "Cannot promote a Pawn to null"); + if (newPiece instanceof Pawn || newPiece instanceof King) { + throw new IllegalArgumentException("Cannot promote a Pawn to a Pawn or King"); + } + if (newPiece.getColor() != pawn.getColor()) { + throw new IllegalArgumentException("Cannot promote a Pawn to a piece of different color"); + } + newPiece.setNeverMoved(false); + promotedToPiece = newPiece; + if (newPieceType == null) { + newPieceType = newPiece.getClass(); + } + capturedPiece = chessBoard.setPiece(newPos, newPiece); + done = true; + } + } + + ///{@inheritDoc} + /// @throws IllegalStateException {@inheritDoc} + @Override + public void undo(ChessBoard chessBoard) { + if (chessBoard.setPiece(newPos, capturedPiece) == null) { + throw new IllegalStateException("No piece to move from " + newPos + " on " + chessBoard); + } + promotedToPiece = null; + // cache newPieceType for UCI + capturedPiece = null; + chessBoard.setPiece(oldPos, movedPiece); + movedPiece = null; + done = false; + } + + /// {@inheritDoc} + /// @throws IllegalStateException {@inheritDoc} + @Override + public String getNotationRepresentation(ChessBoard afterMoveChessBoard, MoveStack afterMoveMoveStack) { + if (!done) { + throw new IllegalStateException("This move wasn't done yet"); + } + if (cacheNotation != null) { + return cacheNotation; + } + return cacheNotation = ChessUtil.commonMoveSuffix( + movedPiece.getColor(), + (capturedPiece == null ? "" : oldPos.getColumn().getCharacter() + "x") + newPos + "=" + promotedToPiece.getNotationRepresentation(), + afterMoveChessBoard, + afterMoveMoveStack + ); + } + + /// {@inheritDoc} + @Override + public String toUCI() { + if (!done) { + throw new IllegalStateException("This move wasn't done yet"); + } + return oldPos.toString() + newPos.toString() + newPieceType.getSimpleName().toLowerCase(Locale.ENGLISH).charAt(0); + } + + /// {@inheritDoc} + @Override + public PawnPromotionMove deepCopy() { + PawnPromotionMove copy = new PawnPromotionMove(oldPos, newPos, pawnPromoter); + copy.newPieceType = this.newPieceType; + copy.movedPiece = this.movedPiece == null ? null : (Pawn) this.movedPiece.clone(); + copy.promotedToPiece = this.promotedToPiece == null ? null : this.promotedToPiece.clone(); + copy.capturedPiece = this.capturedPiece == null ? null : this.capturedPiece.clone(); + copy.cacheNotation = this.cacheNotation; + copy.done = this.done; + return copy; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/movestack/SimpleMove.java b/src/main/java/ru/fokinatorr/chess/impl/movestack/SimpleMove.java new file mode 100644 index 0000000..d5bf578 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/movestack/SimpleMove.java @@ -0,0 +1,119 @@ +package ru.fokinatorr.chess.impl.movestack; + +import lombok.*; +import lombok.experimental.FieldDefaults; +import ru.fokinatorr.chess.impl.ChessBoard; +import ru.fokinatorr.chess.impl.ChessUtil; +import ru.fokinatorr.chess.impl.Position; +import ru.fokinatorr.chess.impl.piece.Pawn; +import ru.fokinatorr.chess.impl.piece.Piece; + +/// A simple move, which is just moving a piece to a different position. +///

+/// Performing this move can end up a piece being captured. You can also retrieve the captured piece. +@Getter +@RequiredArgsConstructor +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString +@FieldDefaults(level = AccessLevel.PRIVATE) +public non-sealed class SimpleMove implements Move { + + @EqualsAndHashCode.Include + final Position oldPos; + @EqualsAndHashCode.Include + final Position newPos; + Piece movedPiece; + boolean wasNeverMoved; + Piece capturedPiece; + @Getter(AccessLevel.NONE) + String cacheNotation; + @Getter(AccessLevel.NONE) + ChessBoard beforeMoveChessBoard; + @Getter(AccessLevel.NONE) + MoveStack beforeMoveMoveStack; + boolean done; + + + /// {@inheritDoc} + /// @throws IllegalStateException {@inheritDoc} + @Override + public void perform(ChessBoard chessBoard, MoveStack moveStack) { + beforeMoveChessBoard = chessBoard.clone(); + beforeMoveMoveStack = moveStack.clone(); + Piece piece = chessBoard.setPiece(oldPos, null); + if (piece == null) { + throw new IllegalStateException("No piece to move from " + oldPos + " on " + chessBoard); + } + capturedPiece = chessBoard.setPiece(newPos, movedPiece = piece); + wasNeverMoved = piece.isNeverMoved(); + piece.setNeverMoved(false); + done = true; + } + + /// {@inheritDoc} + /// @throws IllegalStateException {@inheritDoc} + @Override + public void undo(ChessBoard chessBoard) { + Piece piece = chessBoard.setPiece(newPos, capturedPiece); + if (piece == null) { + throw new IllegalStateException("No piece to move from " + newPos + " on " + chessBoard); + } + movedPiece = capturedPiece = null; + chessBoard.setPiece(oldPos, piece); + piece.setNeverMoved(wasNeverMoved); + beforeMoveChessBoard = null; + beforeMoveMoveStack = null; + done = false; + } + + ///{@inheritDoc} + /// @throws IllegalStateException {@inheritDoc} + @Override + public String getNotationRepresentation(ChessBoard afterMoveChessBoard, MoveStack afterMoveMoveStack) { + if (!done) { + throw new IllegalStateException("This move wasn't done yet"); + } + if (cacheNotation != null) { + return cacheNotation; + } + StringBuilder sb = new StringBuilder(movedPiece.getNotationRepresentation()); + boolean includeColumnInNotation = false, includeRowInNotation = false; + for (Position pos : beforeMoveChessBoard.getPieces().keySet()) { + if (!pos.equals(oldPos) && beforeMoveChessBoard.isPieceOfColorAndType(pos, movedPiece.getColor(), movedPiece.getClass()) + && beforeMoveChessBoard.getMoves(pos, beforeMoveMoveStack).stream().anyMatch(moveEntry -> moveEntry.destPos().equals(newPos))) { + includeColumnInNotation = pos.getRow() == oldPos.getRow(); + includeRowInNotation = pos.getColumn() == oldPos.getColumn(); + } + } + if (includeColumnInNotation || movedPiece instanceof Pawn && capturedPiece != null) { + sb.append(oldPos.getColumn().getCharacter()); + } + if (includeRowInNotation) { + sb.append(oldPos.getRow().getNumber()); + } + if (capturedPiece != null) { + sb.append("x"); + } + return cacheNotation = ChessUtil.commonMoveSuffix(movedPiece.getColor(), sb.append(newPos).toString(), afterMoveChessBoard, afterMoveMoveStack); + } + + /// {@inheritDoc} + @Override + public String toUCI() { + return oldPos.toString() + newPos.toString(); + } + + /// {@inheritDoc} + @Override + public SimpleMove deepCopy() { + SimpleMove copy = new SimpleMove(oldPos, newPos); + copy.movedPiece = this.movedPiece == null ? null : this.movedPiece.clone(); + copy.wasNeverMoved = this.wasNeverMoved; + copy.capturedPiece = this.capturedPiece == null ? null : this.capturedPiece.clone(); + copy.cacheNotation = this.cacheNotation; + copy.beforeMoveChessBoard = this.beforeMoveChessBoard; + copy.beforeMoveMoveStack = this.beforeMoveMoveStack; + copy.done = this.done; + return copy; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/package-info.java b/src/main/java/ru/fokinatorr/chess/impl/package-info.java new file mode 100644 index 0000000..5e01208 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/package-info.java @@ -0,0 +1,2 @@ +/// Implementation of the Chess Logic. +package ru.fokinatorr.chess.impl; \ No newline at end of file diff --git a/src/main/java/ru/fokinatorr/chess/impl/piece/Bishop.java b/src/main/java/ru/fokinatorr/chess/impl/piece/Bishop.java new file mode 100644 index 0000000..fa9973d --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/piece/Bishop.java @@ -0,0 +1,37 @@ +package ru.fokinatorr.chess.impl.piece; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import ru.fokinatorr.chess.impl.ChessUtil; +import ru.fokinatorr.chess.impl.PieceColor; +import ru.fokinatorr.chess.impl.Position; + +import java.util.*; + +import static ru.fokinatorr.chess.impl.piece.ConditionalMovement.*; + +/// @see Bishop on Wikipedia +public non-sealed class Bishop extends Piece { + /// Creates a new bishop. + @JsonCreator + public Bishop(@JsonProperty("color") PieceColor color) { + super(color, "B"); + } + + @Override + public Collection> getPotentialMoves(Position fromPos) { + Collection upRight = new LinkedList<>(), + upLeft = new LinkedList<>(), + downRight = new LinkedList<>(), + downLeft = new LinkedList<>(); + for (int i = 1; i < 8; i++) { + ChessUtil.addIfNotNull(upLeft, upRight(fromPos, i)); + ChessUtil.addIfNotNull(upRight, upLeft(fromPos, i)); + ChessUtil.addIfNotNull(downRight, downRight(fromPos, i)); + ChessUtil.addIfNotNull(downLeft, downLeft(fromPos, i)); + } + return List.of(upRight, upLeft, downLeft, downRight); + } + + +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/piece/ConditionalMovement.java b/src/main/java/ru/fokinatorr/chess/impl/piece/ConditionalMovement.java new file mode 100644 index 0000000..db2d108 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/piece/ConditionalMovement.java @@ -0,0 +1,116 @@ +package ru.fokinatorr.chess.impl.piece; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import ru.fokinatorr.chess.impl.Position; + +import java.util.Objects; + +/// The conditional potentially possible movement of a piece. Can only be applied if the move condition matches. +/// @param pos the position to move to + /// @param moveCondition The condition when the move can only be performed. +public record ConditionalMovement(Position pos, MoveCondition moveCondition) { + + /// Creates an instance of `ConditionalMovement`. + /// @throws NullPointerException if any argument is `null` + public ConditionalMovement { + Objects.requireNonNull(pos, "pos"); + Objects.requireNonNull(moveCondition, "move condition"); + } + + /// Creates an instance of `ConditionalMovement` with {@link MoveCondition#ANY ANY} condition. + /// @throws NullPointerException if any argument is `null` + public ConditionalMovement(Position pos) { + this(pos, MoveCondition.ANY); + } + + + /// Convenience method that modifies the condition of this `ConditionalMovement`. + /// @param condition the new condition + /// @throws NullPointerException if `condition` is `null` + public ConditionalMovement withCondition(MoveCondition condition) { + return new ConditionalMovement(pos, condition); + } + + // Relative movement methods + + public static ConditionalMovement up(Position fromPos, int times) { + Position pos = fromPos.up(times); + return pos == null ? null : new ConditionalMovement(pos); + } + + public static ConditionalMovement down(Position fromPos, int times) { + Position pos = fromPos.down(times); + return pos == null ? null : new ConditionalMovement(pos); + } + + public static ConditionalMovement left(Position fromPos, int times) { + Position pos = fromPos.left(times); + return pos == null ? null : new ConditionalMovement(pos); + } + + public static ConditionalMovement right(Position fromPos, int times) { + Position pos = fromPos.right(times); + return pos == null ? null : new ConditionalMovement(pos); + } + + public static ConditionalMovement upRight(Position fromPos, int times) { + Position pos = fromPos.upRight(times); + return pos == null ? null : new ConditionalMovement(pos); + } + + public static ConditionalMovement upLeft(Position fromPos, int times) { + Position pos = fromPos.upLeft(times); + return pos == null ? null : new ConditionalMovement(pos); + } + + public static ConditionalMovement downRight(Position fromPos, int times) { + Position pos = fromPos.downRight(times); + return pos == null ? null : new ConditionalMovement(pos); + } + + public static ConditionalMovement downLeft(Position fromPos, int times) { + Position pos = fromPos.downLeft(times); + return pos == null ? null : new ConditionalMovement(pos); + } + + public static ConditionalMovement relative(Position fromPos, int up, int right) { + Position pos = fromPos.move(up, right); + return pos == null ? null : new ConditionalMovement(pos); + } + + /// The condition that must match for a piece to move. + @RequiredArgsConstructor + @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) + public enum MoveCondition { + /// The position is accessible in any case except when there's a piece of the same color. + ANY(true, true), + /// The position is accessible only if it's empty. + MOVE_ONLY(true, false), + /// The position is accessible only if there's a piece of the opposite color. + MOVE_AND_CAPTURE_ONLY(false, true), + /// Exclusive to king. + ///

+ /// The move can only be done if all the following conditions are met: + ///

    + ///
  • the king, or any field he's going past, is not attacked;
  • + ///
  • both the king and a rook which is going to take part in the castling had never moved before;
  • + ///
  • there are no pieces between the king and the rook.
  • + ///
+ CASTLING(true, false); + + boolean canMove; + boolean canCapture; + + /// Determines whether the position is accessible if it is empty. + public boolean canMove() { + return canMove; + } + + /// Determines whether the position is accessible if it has a piece of the opposite type on it. + public boolean canCapture() { + return canCapture; + } + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/piece/King.java b/src/main/java/ru/fokinatorr/chess/impl/piece/King.java new file mode 100644 index 0000000..8ce3c48 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/piece/King.java @@ -0,0 +1,39 @@ +package ru.fokinatorr.chess.impl.piece; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import ru.fokinatorr.chess.impl.ChessUtil; +import ru.fokinatorr.chess.impl.PieceColor; +import ru.fokinatorr.chess.impl.Position; + +import java.util.*; + +import static ru.fokinatorr.chess.impl.piece.ConditionalMovement.*; +import static ru.fokinatorr.chess.impl.piece.ConditionalMovement.MoveCondition.CASTLING; + +/// @see King on Wikipedia +public non-sealed class King extends Piece { + /// Creates a new king. + @JsonCreator + public King(@JsonProperty("color") PieceColor color) { + super(color, "K"); + } + + @Override + public Collection> getPotentialMoves(Position fromPos) { + Collection> result = new LinkedList<>(); + ChessUtil.addSingletonListIfNotNull(result, up(fromPos, 1)); + ChessUtil.addSingletonListIfNotNull(result, upRight(fromPos, 1)); + ChessUtil.addSingletonListIfNotNull(result, right(fromPos, 1)); + ChessUtil.addSingletonListIfNotNull(result, downRight(fromPos, 1)); + ChessUtil.addSingletonListIfNotNull(result, down(fromPos, 1)); + ChessUtil.addSingletonListIfNotNull(result, downLeft(fromPos, 1)); + ChessUtil.addSingletonListIfNotNull(result, left(fromPos, 1)); + ChessUtil.addSingletonListIfNotNull(result, upLeft(fromPos, 1)); + if (isNeverMoved()) { + ChessUtil.addIfNotNull(result, left(fromPos, 2), m -> Set.of(m.withCondition(CASTLING))); + ChessUtil.addIfNotNull(result, right(fromPos, 2), m -> Set.of(m.withCondition(CASTLING))); + } + return List.copyOf(result); + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/piece/Knight.java b/src/main/java/ru/fokinatorr/chess/impl/piece/Knight.java new file mode 100644 index 0000000..7da6ab2 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/piece/Knight.java @@ -0,0 +1,35 @@ +package ru.fokinatorr.chess.impl.piece; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import ru.fokinatorr.chess.impl.ChessUtil; +import ru.fokinatorr.chess.impl.PieceColor; +import ru.fokinatorr.chess.impl.Position; + +import java.util.*; + +import static ru.fokinatorr.chess.impl.piece.ConditionalMovement.relative; + +/// @see Knight on Wikipedia +public non-sealed class Knight extends Piece { + /// Creates a new knight. + @JsonCreator + public Knight(@JsonProperty("color") PieceColor color) { + super(color, "N"); + } + + ///{@inheritDoc} + @Override + public Collection> getPotentialMoves(Position fromPos) { + Collection> result = new LinkedList<>(); + ChessUtil.addSingletonListIfNotNull(result, relative(fromPos, 2, 1 )); + ChessUtil.addSingletonListIfNotNull(result, relative(fromPos, 2, -1 )); + ChessUtil.addSingletonListIfNotNull(result, relative(fromPos, -2, -1)); + ChessUtil.addSingletonListIfNotNull(result, relative(fromPos, -2, 1 )); + ChessUtil.addSingletonListIfNotNull(result, relative(fromPos, 1, 2 )); + ChessUtil.addSingletonListIfNotNull(result, relative(fromPos, 1, -2 )); + ChessUtil.addSingletonListIfNotNull(result, relative(fromPos, -1, -2)); + ChessUtil.addSingletonListIfNotNull(result, relative(fromPos, -1, 2 )); + return List.copyOf(result); + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/piece/Pawn.java b/src/main/java/ru/fokinatorr/chess/impl/piece/Pawn.java new file mode 100644 index 0000000..6f758cc --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/piece/Pawn.java @@ -0,0 +1,58 @@ +package ru.fokinatorr.chess.impl.piece; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import ru.fokinatorr.chess.impl.ChessUtil; +import ru.fokinatorr.chess.impl.PieceColor; +import ru.fokinatorr.chess.impl.Position; + +import java.util.*; + +import static ru.fokinatorr.chess.impl.piece.ConditionalMovement.MoveCondition.MOVE_AND_CAPTURE_ONLY; +import static ru.fokinatorr.chess.impl.piece.ConditionalMovement.MoveCondition.MOVE_ONLY; +import static ru.fokinatorr.chess.impl.piece.ConditionalMovement.relative; +import static ru.fokinatorr.chess.impl.piece.ConditionalMovement.up; + +/// @see Pawn on Wikipedia +public non-sealed class Pawn extends Piece { + + /// Creates a new pawn. + @JsonCreator + public Pawn(@JsonProperty("color") PieceColor color) { + super(color, ""); + } + + ///{@inheritDoc} + @Override + public Collection> getPotentialMoves(Position fromPos) { + Collection> result = new LinkedList<>(); + Collection forwardChain = new LinkedList<>(); + ChessUtil.addIfNotNull(forwardChain, up(fromPos, getForwardMovementFactor()), m -> m.withCondition(MOVE_ONLY)); + ChessUtil.addIfNotNull(result, relative(fromPos, getForwardMovementFactor(),1), m -> List.of(m.withCondition(MOVE_AND_CAPTURE_ONLY))); + ChessUtil.addIfNotNull(result, relative(fromPos, getForwardMovementFactor(), -1), m -> List.of(m.withCondition(MOVE_AND_CAPTURE_ONLY))); + if (isNeverMoved()) { + ChessUtil.addIfNotNull(forwardChain, up(fromPos, 2 * getForwardMovementFactor()), m -> m.withCondition(MOVE_ONLY)); + } + result.add(List.copyOf(forwardChain)); + return List.copyOf(result); + } + + // This is required as a pawn's direction of moving depends on their color + @JsonIgnore + public int getForwardMovementFactor() { + return color == PieceColor.WHITE ? 1 : -1; + } + + /// Returns the row on which the `en passant` can be performed by this pawn. + @JsonIgnore + public Position.Row getEnPassantRow() { + return color == PieceColor.WHITE ? Position.Row.FIVE : Position.Row.FOUR; + } + + /// Returns the row on which the `promotion` of this pawn can be performed. + @JsonIgnore + public Position.Row getPromotionRow() { + return color == PieceColor.WHITE ? Position.Row.EIGHT : Position.Row.ONE; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/piece/Piece.java b/src/main/java/ru/fokinatorr/chess/impl/piece/Piece.java new file mode 100644 index 0000000..9cfd6d6 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/piece/Piece.java @@ -0,0 +1,60 @@ +package ru.fokinatorr.chess.impl.piece; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import lombok.Data; +import lombok.EqualsAndHashCode; +import ru.fokinatorr.chess.impl.PieceColor; +import ru.fokinatorr.chess.impl.Position; + +import java.util.Collection; + +/// Common stuff for all chess pieces. +@Data +@JsonTypeInfo( + use = JsonTypeInfo.Id.SIMPLE_NAME, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = Bishop.class, name = "Bishop"), + @JsonSubTypes.Type(value = King.class, name = "King"), + @JsonSubTypes.Type(value = Knight.class, name = "Knight"), + @JsonSubTypes.Type(value = Pawn.class, name = "Pawn"), + @JsonSubTypes.Type(value = Queen.class, name = "Queen"), + @JsonSubTypes.Type(value = Rook.class, name = "Rook"), +}) +public abstract sealed class Piece implements java.io.Serializable, Cloneable permits Bishop, King, Knight, Pawn, Queen, Rook { + + @java.io.Serial + private static final long serialVersionUID = -8808296847757371338L; + + /// This piece's color. + protected final PieceColor color; + /// The piece's chess notation representation. + @JsonIgnore + protected final String notationRepresentation; + /// Whether this piece has never moved before. Needed for pawns, kings and rooks. + @EqualsAndHashCode.Exclude + private boolean neverMoved = true; + + /// Returns the potential doneMoves of this piece, i.e. where it can move on an empty chess board. + @JsonIgnore + public abstract Collection> getPotentialMoves(Position fromPos); + + /// {@inheritDoc} + @Override + public String toString() { + return getClass().getSimpleName() + " (" + notationRepresentation + ") (color=" + color + ")"; + } + + @Override + public Piece clone() { + try { + // all the state of Piece is immutable so leave it like this + return (Piece) super.clone(); + } catch (CloneNotSupportedException e) { + throw new AssertionError(); + } + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/piece/Queen.java b/src/main/java/ru/fokinatorr/chess/impl/piece/Queen.java new file mode 100644 index 0000000..30973ed --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/piece/Queen.java @@ -0,0 +1,43 @@ +package ru.fokinatorr.chess.impl.piece; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import ru.fokinatorr.chess.impl.ChessUtil; +import ru.fokinatorr.chess.impl.PieceColor; +import ru.fokinatorr.chess.impl.Position; + +import java.util.*; + +import static ru.fokinatorr.chess.impl.piece.ConditionalMovement.*; + +/// @see Queen on Wikipedia +public non-sealed class Queen extends Piece { + /// Creates a new queen. + @JsonCreator + public Queen(@JsonProperty("color") PieceColor color) { + super(color, "Q"); + } + + @Override + public Collection> getPotentialMoves(Position fromPos) { + Collection up = new LinkedList<>(), + upRight = new LinkedList<>(), + right = new LinkedList<>(), + downRight = new LinkedList<>(), + down = new LinkedList<>(), + downLeft = new LinkedList<>(), + left = new LinkedList<>(), + upLeft = new LinkedList<>(); + for (int i = 1; i < 8; i++) { + ChessUtil.addIfNotNull(up, up(fromPos, i)); + ChessUtil.addIfNotNull(upRight, upRight(fromPos, i)); + ChessUtil.addIfNotNull(right, right(fromPos, i)); + ChessUtil.addIfNotNull(downRight, downRight(fromPos, i)); + ChessUtil.addIfNotNull(down, down(fromPos, i)); + ChessUtil.addIfNotNull(downLeft, downLeft(fromPos, i)); + ChessUtil.addIfNotNull(left, left(fromPos, i)); + ChessUtil.addIfNotNull(upLeft, upLeft(fromPos, i)); + } + return List.of(up, upRight, right, downRight, down, downLeft, left, upLeft); + } +} diff --git a/src/main/java/ru/fokinatorr/chess/impl/piece/Rook.java b/src/main/java/ru/fokinatorr/chess/impl/piece/Rook.java new file mode 100644 index 0000000..15e6fc8 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/impl/piece/Rook.java @@ -0,0 +1,35 @@ +package ru.fokinatorr.chess.impl.piece; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import ru.fokinatorr.chess.impl.ChessUtil; +import ru.fokinatorr.chess.impl.PieceColor; +import ru.fokinatorr.chess.impl.Position; + +import java.util.*; + +import static ru.fokinatorr.chess.impl.piece.ConditionalMovement.*; + +/// @see Rook on Wikipedia +public non-sealed class Rook extends Piece { + /// Creates a new rook. + @JsonCreator + public Rook(@JsonProperty("color") PieceColor color) { + super(color, "R"); + } + + @Override + public Collection> getPotentialMoves(Position fromPos) { + Collection up = new LinkedList<>(), + down = new LinkedList<>(), + left = new LinkedList<>(), + right = new LinkedList<>(); + for (int i = 1; i < 8; i++) { + ChessUtil.addIfNotNull(up, up(fromPos, i)); + ChessUtil.addIfNotNull(down, down(fromPos, i)); + ChessUtil.addIfNotNull(left, left(fromPos, i)); + ChessUtil.addIfNotNull(right, right(fromPos, i)); + } + return List.of(up, down, left, right); + } +} diff --git a/src/main/java/ru/fokinatorr/chess/misc/Pair.java b/src/main/java/ru/fokinatorr/chess/misc/Pair.java new file mode 100644 index 0000000..48d7d85 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/misc/Pair.java @@ -0,0 +1,8 @@ +package ru.fokinatorr.chess.misc; + +import lombok.Builder; + +/// A pair of two objects. +@Builder(toBuilder = true) +public record Pair(A first, B second) { +} diff --git a/src/main/java/ru/fokinatorr/chess/server/UserDTO.java b/src/main/java/ru/fokinatorr/chess/server/UserDTO.java new file mode 100644 index 0000000..319fd02 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/UserDTO.java @@ -0,0 +1,9 @@ +package ru.fokinatorr.chess.server; + +import lombok.Builder; +import lombok.NonNull; + +/// Data-transfer object representing a registered `User`. +@Builder(toBuilder = true) +public record UserDTO(long id, @NonNull String username) { +} diff --git a/src/main/java/ru/fokinatorr/chess/server/exception/CannotAcceptOrRefuseDrawException.java b/src/main/java/ru/fokinatorr/chess/server/exception/CannotAcceptOrRefuseDrawException.java new file mode 100644 index 0000000..4ce9c55 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/exception/CannotAcceptOrRefuseDrawException.java @@ -0,0 +1,10 @@ +package ru.fokinatorr.chess.server.exception; + +import java.util.UUID; + +/// Thrown to indicate that a user with the given ID can not accept or refuse draw in game with the given ID. This means that a draw WAS requested by someone in that game. +public class CannotAcceptOrRefuseDrawException extends GameAccessDeniedException { + public CannotAcceptOrRefuseDrawException(long userId, UUID gameId) { + super(userId, gameId); + } +} diff --git a/src/main/java/ru/fokinatorr/chess/server/exception/DrawAlreadyOfferedException.java b/src/main/java/ru/fokinatorr/chess/server/exception/DrawAlreadyOfferedException.java new file mode 100644 index 0000000..2a69581 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/exception/DrawAlreadyOfferedException.java @@ -0,0 +1,20 @@ +package ru.fokinatorr.chess.server.exception; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +import java.util.UUID; + +/// Exception thrown to indicate that draw had already been offered in the game with the given ID. +@RequiredArgsConstructor +@ResponseStatus(HttpStatus.FORBIDDEN) +public class DrawAlreadyOfferedException extends RuntimeException { + + private final UUID gameId; + + @Override + public String getMessage() { + return "In game with ID: " + gameId; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/server/exception/DrawNotOfferedException.java b/src/main/java/ru/fokinatorr/chess/server/exception/DrawNotOfferedException.java new file mode 100644 index 0000000..3972393 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/exception/DrawNotOfferedException.java @@ -0,0 +1,20 @@ +package ru.fokinatorr.chess.server.exception; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +import java.util.UUID; + +/// Exception thrown to indicate that draw wasn't offered in the game with the given ID. +@RequiredArgsConstructor +@ResponseStatus(HttpStatus.FORBIDDEN) +public class DrawNotOfferedException extends RuntimeException { + + private final UUID gameId; + + @Override + public String getMessage() { + return "In game with ID: " + gameId; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/server/exception/GameAccessDeniedException.java b/src/main/java/ru/fokinatorr/chess/server/exception/GameAccessDeniedException.java new file mode 100644 index 0000000..453eb6e --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/exception/GameAccessDeniedException.java @@ -0,0 +1,22 @@ +package ru.fokinatorr.chess.server.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +import java.util.UUID; + +/// Thrown to indicate that a user with the given ID can not access a game with the given ID for whatever reason. +@Getter +@RequiredArgsConstructor +@InheritedResponseStatus(HttpStatus.FORBIDDEN) +public abstract class GameAccessDeniedException extends RuntimeException { + + private final long userId; + private final UUID gameId; + + @Override + public String getMessage() { + return "For user with ID: " + userId + " on game with ID: " + gameId; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/server/exception/GameCancelledException.java b/src/main/java/ru/fokinatorr/chess/server/exception/GameCancelledException.java new file mode 100644 index 0000000..158c7ba --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/exception/GameCancelledException.java @@ -0,0 +1,19 @@ +package ru.fokinatorr.chess.server.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.UUID; + +/// Special exception emitted by game join event `Mono` when a game gets cancelled. +@Getter +@RequiredArgsConstructor +public class GameCancelledException extends RuntimeException { + + private final UUID gameId; + + @Override + public String getMessage() { + return "With ID: " + gameId; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/server/exception/GameNotFoundException.java b/src/main/java/ru/fokinatorr/chess/server/exception/GameNotFoundException.java new file mode 100644 index 0000000..54102c0 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/exception/GameNotFoundException.java @@ -0,0 +1,22 @@ +package ru.fokinatorr.chess.server.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +import java.util.UUID; + +/// Exception thrown to indicate that a game with the given ID was not found. +@Getter +@RequiredArgsConstructor +@ResponseStatus(HttpStatus.NOT_FOUND) +public class GameNotFoundException extends RuntimeException { + + private final UUID id; + + @Override + public String getMessage() { + return "With ID: " + id; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/server/exception/InheritedResponseStatus.java b/src/main/java/ru/fokinatorr/chess/server/exception/InheritedResponseStatus.java new file mode 100644 index 0000000..8255f82 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/exception/InheritedResponseStatus.java @@ -0,0 +1,30 @@ +package ru.fokinatorr.chess.server.exception; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +import java.lang.annotation.*; + +/// An inherited variant of [ResponseStatus]. +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@ResponseStatus +@Inherited +public @interface InheritedResponseStatus { + + /// Alias for [ResponseStatus#value()]. + @AliasFor(annotation = ResponseStatus.class) + HttpStatus value() default HttpStatus.INTERNAL_SERVER_ERROR; + + + /// Alias for [ResponseStatus#code()]. + @AliasFor(annotation = ResponseStatus.class) + HttpStatus code() default HttpStatus.INTERNAL_SERVER_ERROR; + + + /// Alias for [ResponseStatus#reason()()]. + @AliasFor(annotation = ResponseStatus.class) + String reason() default ""; +} diff --git a/src/main/java/ru/fokinatorr/chess/server/exception/InvalidMoveException.java b/src/main/java/ru/fokinatorr/chess/server/exception/InvalidMoveException.java new file mode 100644 index 0000000..c53f7f4 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/exception/InvalidMoveException.java @@ -0,0 +1,22 @@ +package ru.fokinatorr.chess.server.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; +import ru.fokinatorr.chess.server.game.ChessBoardAndMoveStackDTO; + +/// Thrown to indicate that some move is invalid. +@Getter +@RequiredArgsConstructor +@ResponseStatus(HttpStatus.BAD_REQUEST) +public class InvalidMoveException extends RuntimeException { + + private final String uciMove; + private final ChessBoardAndMoveStackDTO chessBoardAndMoveStack; + + @Override + public String getMessage() { + return "Move " + uciMove + " on " + chessBoardAndMoveStack; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/server/exception/MoveAlreadyDoneException.java b/src/main/java/ru/fokinatorr/chess/server/exception/MoveAlreadyDoneException.java new file mode 100644 index 0000000..b69cddd --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/exception/MoveAlreadyDoneException.java @@ -0,0 +1,16 @@ +package ru.fokinatorr.chess.server.exception; + +import java.util.UUID; + +/// Thrown to indicate that a user with the given ID can not access the game because someone had already done a move. +public class MoveAlreadyDoneException extends GameAccessDeniedException { + + public MoveAlreadyDoneException(UUID gameId) { + super(-1, gameId); + } + + @Override + public String getMessage() { + return "In game with ID: " + getGameId(); + } +} diff --git a/src/main/java/ru/fokinatorr/chess/server/exception/MoveOrderException.java b/src/main/java/ru/fokinatorr/chess/server/exception/MoveOrderException.java new file mode 100644 index 0000000..a211d09 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/exception/MoveOrderException.java @@ -0,0 +1,11 @@ +package ru.fokinatorr.chess.server.exception; + +import java.util.UUID; + +/// Thrown to indicate that a user with the given ID can access the game, but it's not their turn to move. +public class MoveOrderException extends GameAccessDeniedException { + + public MoveOrderException(long userId, UUID gameId) { + super(userId, gameId); + } +} diff --git a/src/main/java/ru/fokinatorr/chess/server/exception/NotParticipantException.java b/src/main/java/ru/fokinatorr/chess/server/exception/NotParticipantException.java new file mode 100644 index 0000000..a6b0393 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/exception/NotParticipantException.java @@ -0,0 +1,11 @@ +package ru.fokinatorr.chess.server.exception; + +import java.util.UUID; + +/// Thrown to indicate that a user with the given ID can not access the game because they aren't its participant. +public class NotParticipantException extends GameAccessDeniedException { + + public NotParticipantException(long userId, UUID gameId) { + super(userId, gameId); + } +} diff --git a/src/main/java/ru/fokinatorr/chess/server/exception/UserNotFoundException.java b/src/main/java/ru/fokinatorr/chess/server/exception/UserNotFoundException.java new file mode 100644 index 0000000..0645879 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/exception/UserNotFoundException.java @@ -0,0 +1,20 @@ +package ru.fokinatorr.chess.server.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/// Thrown to indicate that a user with the given ID does not exist. +@Getter +@RequiredArgsConstructor +@ResponseStatus(HttpStatus.NOT_FOUND) +public class UserNotFoundException extends RuntimeException { + + private final long userId; + + @Override + public String getMessage() { + return "With ID: " + userId; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/server/game/ChessBoardAndMoveStackDTO.java b/src/main/java/ru/fokinatorr/chess/server/game/ChessBoardAndMoveStackDTO.java new file mode 100644 index 0000000..f16aa8a --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/game/ChessBoardAndMoveStackDTO.java @@ -0,0 +1,41 @@ +package ru.fokinatorr.chess.server.game; + +import lombok.Builder; +import lombok.NonNull; +import ru.fokinatorr.chess.impl.ChessBoard; +import ru.fokinatorr.chess.impl.PieceColor; + +import java.util.Arrays; +import java.util.Objects; + +/// A data-transfer object representing a chess board and move stack. +@Builder(toBuilder = true) +public record ChessBoardAndMoveStackDTO(@NonNull ChessBoard initialBoard, + @NonNull String[] doneMoves, + @NonNull String[] undoneMoves, + @NonNull PieceColor currentlyMoving) { + + + @Override + @NonNull + public String toString() { + return "ChessBoardAndMoveStackDTO{" + + "initialBoard=" + (initialBoard.equals(ChessBoard.startPosition()) ? "ChessBoard.startPosition()" : initialBoard) + + ", doneMoves=" + Arrays.toString(doneMoves) + + ", undoneMoves=" + Arrays.toString(undoneMoves) + + ", currentlyMoving=" + currentlyMoving + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ChessBoardAndMoveStackDTO that)) return false; + return Objects.deepEquals(doneMoves, that.doneMoves) && Objects.deepEquals(undoneMoves, that.undoneMoves) && Objects.equals(initialBoard, that.initialBoard) && currentlyMoving == that.currentlyMoving; + } + + @Override + public int hashCode() { + return Objects.hash(initialBoard, Arrays.hashCode(doneMoves), Arrays.hashCode(undoneMoves), currentlyMoving); + } +} diff --git a/src/main/java/ru/fokinatorr/chess/server/game/CreateGameOptions.java b/src/main/java/ru/fokinatorr/chess/server/game/CreateGameOptions.java new file mode 100644 index 0000000..95e1d71 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/game/CreateGameOptions.java @@ -0,0 +1,10 @@ +package ru.fokinatorr.chess.server.game; + +import lombok.Builder; +import lombok.NonNull; +import ru.fokinatorr.chess.impl.PieceColorOrRandom; + +/// A list of fields accepted in `POST` to `/game/create`. +@Builder(toBuilder = true) +public record CreateGameOptions(@NonNull PieceColorOrRandom playAs) { +} diff --git a/src/main/java/ru/fokinatorr/chess/server/game/FinishedGameDTO.java b/src/main/java/ru/fokinatorr/chess/server/game/FinishedGameDTO.java new file mode 100644 index 0000000..ec11103 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/game/FinishedGameDTO.java @@ -0,0 +1,19 @@ +package ru.fokinatorr.chess.server.game; + +import lombok.Builder; +import lombok.NonNull; +import ru.fokinatorr.chess.impl.PieceColor; +import ru.fokinatorr.chess.server.UserDTO; +import ru.fokinatorr.chess.server.game.event.GameFinishedEvent; + +import java.util.UUID; + +/// Data-transfer object representing a finished chess game. +@Builder(toBuilder = true) +public record FinishedGameDTO(@NonNull UUID id, + @NonNull UserDTO white, + @NonNull UserDTO black, + @NonNull ChessBoardAndMoveStackDTO chessBoardAndMoveStack, + @NonNull PieceColor winner, + @NonNull GameFinishedEvent.GameFinishCause finishCause) { +} diff --git a/src/main/java/ru/fokinatorr/chess/server/game/OngoingGameDTO.java b/src/main/java/ru/fokinatorr/chess/server/game/OngoingGameDTO.java new file mode 100644 index 0000000..f91d027 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/game/OngoingGameDTO.java @@ -0,0 +1,15 @@ +package ru.fokinatorr.chess.server.game; + +import lombok.Builder; +import lombok.NonNull; +import ru.fokinatorr.chess.server.UserDTO; + +import java.util.UUID; + +/// Data-transfer object representing an ongoing chess game. +@Builder(toBuilder = true) +public record OngoingGameDTO(@NonNull UUID id, + @NonNull UserDTO white, + @NonNull UserDTO black, + @NonNull ChessBoardAndMoveStackDTO chessBoardAndMoveStack) { +} diff --git a/src/main/java/ru/fokinatorr/chess/server/game/PendingGameDTO.java b/src/main/java/ru/fokinatorr/chess/server/game/PendingGameDTO.java new file mode 100644 index 0000000..ef55968 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/game/PendingGameDTO.java @@ -0,0 +1,15 @@ +package ru.fokinatorr.chess.server.game; + +import lombok.Builder; +import lombok.NonNull; +import ru.fokinatorr.chess.impl.PieceColorOrRandom; +import ru.fokinatorr.chess.server.UserDTO; + +import java.util.UUID; + +/// Data-transfer object representing a pending chess game. +@Builder(toBuilder = true) +public record PendingGameDTO(@NonNull UUID id, + @NonNull UserDTO withUser, + @NonNull PieceColorOrRandom ownerPlaysAs) { +} diff --git a/src/main/java/ru/fokinatorr/chess/server/game/event/DrawOfferedEvent.java b/src/main/java/ru/fokinatorr/chess/server/game/event/DrawOfferedEvent.java new file mode 100644 index 0000000..aafcf35 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/game/event/DrawOfferedEvent.java @@ -0,0 +1,8 @@ +package ru.fokinatorr.chess.server.game.event; + +import ru.fokinatorr.chess.impl.PieceColor; + +/// The user playing as the specified color offered a draw. +/// @param byColor the color that offered a draw +public record DrawOfferedEvent(PieceColor byColor) implements GameEvent { +} diff --git a/src/main/java/ru/fokinatorr/chess/server/game/event/DrawRefusedEvent.java b/src/main/java/ru/fokinatorr/chess/server/game/event/DrawRefusedEvent.java new file mode 100644 index 0000000..29274b8 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/game/event/DrawRefusedEvent.java @@ -0,0 +1,8 @@ +package ru.fokinatorr.chess.server.game.event; + +import ru.fokinatorr.chess.impl.PieceColor; + +/// The user playing as the specified color refused draw. +/// @param byColor the color that refused draw +public record DrawRefusedEvent(PieceColor byColor) implements GameEvent { +} diff --git a/src/main/java/ru/fokinatorr/chess/server/game/event/GameEvent.java b/src/main/java/ru/fokinatorr/chess/server/game/event/GameEvent.java new file mode 100644 index 0000000..505b762 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/game/event/GameEvent.java @@ -0,0 +1,29 @@ +package ru.fokinatorr.chess.server.game.event; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/// An event broadcasted by a server to clients so that they could sync their state with server's. +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + property = "type" +) +@JsonSubTypes({ + @Type(value = GameFinishedEvent.class, name = "game_finished"), + @Type(value = MovePerformedEvent.class, name = "move_performed"), + @Type(value = DrawOfferedEvent.class, name = "draw_offered"), + @Type(value = DrawRefusedEvent.class, name = "draw_refused"), + @Type(value = GameJoinedEvent.class, name = "game_joined"), + @Type(value = MoveUndoneEvent.class, name = "move_undone"), + @Type(value = MoveRedoneEvent.class, name = "move_redone"), +}) +public interface GameEvent { + + /// Indicates whether this event is terminal, i.e. if the client should expect the `onComplete` signal after it. + @JsonIgnore + default boolean isTerminal() { + return false; + } +} \ No newline at end of file diff --git a/src/main/java/ru/fokinatorr/chess/server/game/event/GameFinishedEvent.java b/src/main/java/ru/fokinatorr/chess/server/game/event/GameFinishedEvent.java new file mode 100644 index 0000000..0a07409 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/game/event/GameFinishedEvent.java @@ -0,0 +1,30 @@ +package ru.fokinatorr.chess.server.game.event; + +import ru.fokinatorr.chess.impl.PieceColor; +import ru.fokinatorr.chess.server.game.ChessBoardAndMoveStackDTO; + +/// An event indicating that the game has finished for some reason. This is the only terminal event yet. +/// @param winner the pieces' color that won, or `null` if none + /// @param cause the cause of the game end + /// @param chessBoardAndMoveStack the fallback chess board +public record GameFinishedEvent(PieceColor winner, + GameFinishCause cause, + ChessBoardAndMoveStackDTO chessBoardAndMoveStack) implements GameEvent { + + /// What caused the game to finish. + public enum GameFinishCause { + CANCELLED, + DRAW_ACCEPTED, + GIVE_UP, + TIME_OVERSTAYED, // currently not in use (NOTE: time overstayed means checkmate) + CHECKMATE, + STALEMATE, + OTHER // currently not in use + } + + /// {@inheritDoc} + @Override + public boolean isTerminal() { + return true; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/server/game/event/GameJoinedEvent.java b/src/main/java/ru/fokinatorr/chess/server/game/event/GameJoinedEvent.java new file mode 100644 index 0000000..2f9e9e1 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/game/event/GameJoinedEvent.java @@ -0,0 +1,15 @@ +package ru.fokinatorr.chess.server.game.event; + +import lombok.NonNull; +import ru.fokinatorr.chess.impl.ChessBoard; +import ru.fokinatorr.chess.impl.PieceColor; +import ru.fokinatorr.chess.server.UserDTO; + +/// An event indicating that someone joined the game. +/// @param startPosition the start game position + /// @param playingAs the color that the owner plays as + /// @param playingWith the user that the owner plays with +public record GameJoinedEvent(@NonNull ChessBoard startPosition, + @NonNull PieceColor playingAs, + @NonNull UserDTO playingWith) implements GameEvent { +} diff --git a/src/main/java/ru/fokinatorr/chess/server/game/event/MovePerformedEvent.java b/src/main/java/ru/fokinatorr/chess/server/game/event/MovePerformedEvent.java new file mode 100644 index 0000000..c069d4c --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/game/event/MovePerformedEvent.java @@ -0,0 +1,9 @@ +package ru.fokinatorr.chess.server.game.event; + +import ru.fokinatorr.chess.server.game.ChessBoardAndMoveStackDTO; + +/// Some player performed a move. +/// @param move the move, in UCI format + /// @param chessBoardAndMoveStack the fallback chess board, if performing the move on client-side fails +public record MovePerformedEvent(String move, ChessBoardAndMoveStackDTO chessBoardAndMoveStack) implements GameEvent { +} diff --git a/src/main/java/ru/fokinatorr/chess/server/game/event/MoveRedoneEvent.java b/src/main/java/ru/fokinatorr/chess/server/game/event/MoveRedoneEvent.java new file mode 100644 index 0000000..5a7f672 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/game/event/MoveRedoneEvent.java @@ -0,0 +1,8 @@ +package ru.fokinatorr.chess.server.game.event; + +import ru.fokinatorr.chess.server.game.ChessBoardAndMoveStackDTO; + +/// A move was redone. Only supported in local/offline games. + /// @param chessBoardAndMoveStack the fallback chess board +public record MoveRedoneEvent(ChessBoardAndMoveStackDTO chessBoardAndMoveStack) implements GameEvent { +} diff --git a/src/main/java/ru/fokinatorr/chess/server/game/event/MoveUndoneEvent.java b/src/main/java/ru/fokinatorr/chess/server/game/event/MoveUndoneEvent.java new file mode 100644 index 0000000..fc87539 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/game/event/MoveUndoneEvent.java @@ -0,0 +1,8 @@ +package ru.fokinatorr.chess.server.game.event; + +import ru.fokinatorr.chess.server.game.ChessBoardAndMoveStackDTO; + +/// A move was undone. Only supported in local/offline games. + /// @param chessBoardAndMoveStack the fallback chess board +public record MoveUndoneEvent(ChessBoardAndMoveStackDTO chessBoardAndMoveStack) implements GameEvent { +} diff --git a/src/main/java/ru/fokinatorr/chess/server/package-info.java b/src/main/java/ru/fokinatorr/chess/server/package-info.java new file mode 100644 index 0000000..976eae3 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/package-info.java @@ -0,0 +1,2 @@ +/// Some POJOs returned by the server. +package ru.fokinatorr.chess.server; \ No newline at end of file diff --git a/src/main/java/ru/fokinatorr/chess/server/security/LoginRequirements.java b/src/main/java/ru/fokinatorr/chess/server/security/LoginRequirements.java new file mode 100644 index 0000000..2f47f25 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/security/LoginRequirements.java @@ -0,0 +1,14 @@ +package ru.fokinatorr.chess.server.security; + +import lombok.Builder; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.Value; + +import java.util.Set; + +/// A list of fields returned from `GET` request to `/login`. +@Builder(toBuilder = true) +public record LoginRequirements(@NonNull Set requiredFields) { + +} diff --git a/src/main/java/ru/fokinatorr/chess/server/security/RegisterForm.java b/src/main/java/ru/fokinatorr/chess/server/security/RegisterForm.java new file mode 100644 index 0000000..c2016a7 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/security/RegisterForm.java @@ -0,0 +1,5 @@ +package ru.fokinatorr.chess.server.security; + +/// A list of fields accepted in `POST` to `/register`. +public record RegisterForm(String username, String password, String confirmPassword) { +} diff --git a/src/main/java/ru/fokinatorr/chess/server/service/DTOService.java b/src/main/java/ru/fokinatorr/chess/server/service/DTOService.java new file mode 100644 index 0000000..6dc6e76 --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/server/service/DTOService.java @@ -0,0 +1,42 @@ +package ru.fokinatorr.chess.server.service; + +import ru.fokinatorr.chess.impl.ChessBoard; +import ru.fokinatorr.chess.impl.ChessUtil; +import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack; +import ru.fokinatorr.chess.impl.movestack.Move; +import ru.fokinatorr.chess.server.game.ChessBoardAndMoveStackDTO; + +/// Data-transfer-object related service. +public class DTOService { + + /// Creates and returns a DTO for [ChessBoardAndMoveStack]. + public ChessBoardAndMoveStackDTO toDTO(ChessBoardAndMoveStack chessBoardAndMoveStack, ChessBoard initialBoard) { + String[] doneMoves = new String[chessBoardAndMoveStack.getMoveStack().getDoneMoves().size()]; + String[] undoneMoves = new String[chessBoardAndMoveStack.getMoveStack().getUndoneMoves().size()]; + int i = 0; + for (Move move : chessBoardAndMoveStack.getMoveStack().getDoneMoves()) { + doneMoves[i++] = move.toUCI(); + } + i = 0; + for (Move move : chessBoardAndMoveStack.getMoveStack().getUndoneMoves()) { + undoneMoves[i++] = move.toUCI(); + } + return new ChessBoardAndMoveStackDTO(initialBoard.clone(), doneMoves, undoneMoves, chessBoardAndMoveStack.getChessBoard().getCurrentlyMoving()); + } + + /// Converts the DTO to a [ChessBoardAndMoveStack]. + public ChessBoardAndMoveStack fromDTO(ChessBoardAndMoveStackDTO dto) { + ChessBoardAndMoveStack chessBoardAndMoveStack = new ChessBoardAndMoveStack(dto.initialBoard()); + for (String move : dto.doneMoves()) { + chessBoardAndMoveStack.perform(ChessUtil.parseUCIMove(move, chessBoardAndMoveStack)); + } + for (int i = dto.undoneMoves().length - 1; i >= 0; i--) { + chessBoardAndMoveStack.perform(ChessUtil.parseUCIMove(dto.undoneMoves()[i], chessBoardAndMoveStack)); + } + for (int i = 0; i < dto.undoneMoves().length; i++) { + chessBoardAndMoveStack.undo(); + } + chessBoardAndMoveStack.getChessBoard().setCurrentlyMoving(dto.currentlyMoving()); + return chessBoardAndMoveStack; + } +} diff --git a/src/main/java/ru/fokinatorr/chess/util/ThrowingRunnable.java b/src/main/java/ru/fokinatorr/chess/util/ThrowingRunnable.java new file mode 100644 index 0000000..430edfc --- /dev/null +++ b/src/main/java/ru/fokinatorr/chess/util/ThrowingRunnable.java @@ -0,0 +1,41 @@ +package ru.fokinatorr.chess.util; + +import java.util.concurrent.Callable; + +/// A [Runnable] that may throw an exception [T]. +/// @param the type of the exception that may be thrown +public interface ThrowingRunnable extends Runnable { + + /// Runs this operation. + /// @throws T the exception thrown by this operation + void throwingRun() throws T; + + /// Runs this operation without throwing checked exceptions. + /// By default, any checked exceptions are wrapped into a [RuntimeException]; any unchecked exceptions (subclasses + /// of `RuntimeException` or `Error`) are rethrown as is. + @Override + default void run() { + try { + throwingRun(); + } catch (Throwable e) { + if (e instanceof RuntimeException e1) { + throw e1; + } else if (e instanceof Error e1) { + throw e1; + } else { + throw new RuntimeException(e); + } + } + } + + /// Converts a `ThrowingRunnable<`[Exception]`>` into a [Callable] that delegates to it and + /// returns `null`. + /// @param throwingRun the runnable + /// @return the input runnable as a callable + static Callable toCallable(ThrowingRunnable throwingRun) { + return () -> { + throwingRun.run(); + return null; + }; + } +} diff --git a/src/test/java/ru/fokinatorr/chess/ChessLogicTests.java b/src/test/java/ru/fokinatorr/chess/ChessLogicTests.java new file mode 100644 index 0000000..abc6dfa --- /dev/null +++ b/src/test/java/ru/fokinatorr/chess/ChessLogicTests.java @@ -0,0 +1,600 @@ +package ru.fokinatorr.chess; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import ru.fokinatorr.chess.impl.*; +import ru.fokinatorr.chess.impl.movestack.CastlingMove; +import ru.fokinatorr.chess.impl.movestack.EnPassantMove; +import ru.fokinatorr.chess.impl.movestack.PawnPromotionMove; +import ru.fokinatorr.chess.impl.movestack.SimpleMove; +import ru.fokinatorr.chess.impl.piece.*; +import ru.fokinatorr.chess.impl.misc.ChessBoardAndMoveStack; + +import java.util.Collection; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; + +import static java.util.Map.entry; +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +class ChessLogicTests { + + static final Map> CHAR_TO_PIECE = Map.ofEntries( + entry('R', () -> new Rook(PieceColor.WHITE)), + entry('N', () -> new Knight(PieceColor.WHITE)), + entry('B', () -> new Bishop(PieceColor.WHITE)), + entry('Q', () -> new Queen(PieceColor.WHITE)), + entry('K', () -> new King(PieceColor.WHITE)), + entry('P', () -> new Pawn(PieceColor.WHITE)), + entry('r', () -> new Rook(PieceColor.BLACK)), + entry('n', () -> new Knight(PieceColor.BLACK)), + entry('b', () -> new Bishop(PieceColor.BLACK)), + entry('q', () -> new Queen(PieceColor.BLACK)), + entry('k', () -> new King(PieceColor.BLACK)), + entry('p', () -> new Pawn(PieceColor.BLACK)), + entry(' ', () -> null) + ); + + ChessBoardAndMoveStack fromString(String s) { + ChessBoard chessBoard = new ChessBoard(); + String[] rows = s.split("\\n"); + assertEquals(8, rows.length); + for (int i = 0; i < 8; i++) { + assertEquals(8, rows[i].length()); + for (int j = 0; j < 8; j++) { + char c = rows[i].charAt(j); + Position pos = Position.valueOf(j, 7 - i); + Supplier sup = CHAR_TO_PIECE.get(c); + if (sup != null) { + chessBoard.setPiece(pos, sup.get()); + } + } + } + return new ChessBoardAndMoveStack(chessBoard); + } + + @Test + void testPieces() { + ChessBoardAndMoveStack chessBoardAndMoveStack = ChessBoardAndMoveStack.startPosition(); + + assertEquals(new Pawn(PieceColor.WHITE), chessBoardAndMoveStack.getPiece(Position.valueOf("e2"))); + assertEquals(new King(PieceColor.WHITE), chessBoardAndMoveStack.getPiece(Position.valueOf("e1"))); + assertEquals(new Knight(PieceColor.BLACK), chessBoardAndMoveStack.getPiece(Position.valueOf("b8"))); + assertNull(chessBoardAndMoveStack.getPiece(Position.valueOf("b3"))); + } + + @Test + void testSimpleMoves() { + ChessBoardAndMoveStack chessBoardAndMoveStack = ChessBoardAndMoveStack.startPosition(); + + Collection e2Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("e2")); + assertEquals(2, e2Moves.size()); + assertTrue(e2Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e2") + && simpleMove.getNewPos().toString().equals("e4"))); + assertTrue(e2Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e2") + && simpleMove.getNewPos().toString().equals("e3"))); + + Collection e1Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("e1")); + assertEquals(0, e1Moves.size()); + + Collection h1Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("h1")); + assertEquals(0, h1Moves.size()); + + Collection b8Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("b8")); + assertEquals(2, b8Moves.size()); + assertTrue(b8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("b8") + && simpleMove.getNewPos().toString().equals("a6"))); + assertTrue(b8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("b8") + && simpleMove.getNewPos().toString().equals("c6"))); + } + + @Test + void testCastling() { + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + r k r + \s + \s + \s + \s + \s + \s + R K R"""); + Collection e1Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("e1")); + assertEquals(7, e1Moves.size()); + assertTrue(e1Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e1") + && simpleMove.getNewPos().toString().equals("d1"))); + assertTrue(e1Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e1") + && simpleMove.getNewPos().toString().equals("d2"))); + assertTrue(e1Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e1") + && simpleMove.getNewPos().toString().equals("e2"))); + assertTrue(e1Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e1") + && simpleMove.getNewPos().toString().equals("f2"))); + assertTrue(e1Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e1") + && simpleMove.getNewPos().toString().equals("f1"))); + assertTrue(e1Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof CastlingMove castlingMove + && castlingMove.getKingOldPos().toString().equals("e1") + && castlingMove.getRookOldPos().toString().equals("a1") + && !castlingMove.isKingside())); + assertTrue(e1Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof CastlingMove castlingMove + && castlingMove.getKingOldPos().toString().equals("e1") + && castlingMove.getRookOldPos().toString().equals("h1") + && castlingMove.isKingside())); + Collection e8Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("e8")); + assertEquals(7, e8Moves.size()); + assertTrue(e8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e8") + && simpleMove.getNewPos().toString().equals("d8"))); + assertTrue(e8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e8") + && simpleMove.getNewPos().toString().equals("d7"))); + assertTrue(e8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e8") + && simpleMove.getNewPos().toString().equals("e7"))); + assertTrue(e8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e8") + && simpleMove.getNewPos().toString().equals("f7"))); + assertTrue(e8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e8") + && simpleMove.getNewPos().toString().equals("f8"))); + assertTrue(e8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof CastlingMove castlingMove + && castlingMove.getKingOldPos().toString().equals("e8") + && castlingMove.getRookOldPos().toString().equals("a8") + && !castlingMove.isKingside())); + assertTrue(e8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof CastlingMove castlingMove + && castlingMove.getKingOldPos().toString().equals("e8") + && castlingMove.getRookOldPos().toString().equals("h8") + && castlingMove.isKingside())); + } + + @Test + void testCastlingFails1() { + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + \s + \s + \s + b \s + \s + \s + \s + K R"""); // white king is in check + assertTrue(chessBoardAndMoveStack.isCheck(PieceColor.WHITE)); + Collection e1Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("e1")); + assertEquals(4, e1Moves.size()); + assertTrue(e1Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e1") + && simpleMove.getNewPos().toString().equals("d1"))); + assertTrue(e1Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e1") + && simpleMove.getNewPos().toString().equals("e2"))); + assertTrue(e1Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e1") + && simpleMove.getNewPos().toString().equals("f2"))); + assertTrue(e1Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e1") + && simpleMove.getNewPos().toString().equals("f1"))); + } + + @Test + void testCastlingFails2() { + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + r k \s + \s + \s + \s + \s + \s + \s + QK \s"""); // the field that black king jumps over is attacked by queen + assertFalse(chessBoardAndMoveStack.isCheck(PieceColor.WHITE)); + assertFalse(chessBoardAndMoveStack.isCheck(PieceColor.BLACK)); + Collection e8Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("e8")); + assertEquals(3, e8Moves.size()); + assertTrue(e8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e8") + && simpleMove.getNewPos().toString().equals("e7"))); + assertTrue(e8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e8") + && simpleMove.getNewPos().toString().equals("f7"))); + assertTrue(e8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e8") + && simpleMove.getNewPos().toString().equals("f8"))); + } + + @Test + void testCastlingFails3() { + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + r k \s + \s + \s + \s + \s + q\s + \s + R K \s"""); // the field that black king ends up being in is attacked by white rook; black queen attacks white king + assertTrue(chessBoardAndMoveStack.isCheck(PieceColor.WHITE)); + assertFalse(chessBoardAndMoveStack.isCheck(PieceColor.BLACK)); + Collection e8Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("e8")); + assertEquals(5, e8Moves.size()); + assertTrue(e8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e8") + && simpleMove.getNewPos().toString().equals("e7"))); + assertTrue(e8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e8") + && simpleMove.getNewPos().toString().equals("f7"))); + assertTrue(e8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e8") + && simpleMove.getNewPos().toString().equals("f8"))); + assertTrue(e8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e8") + && simpleMove.getNewPos().toString().equals("d7"))); + assertTrue(e8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e8") + && simpleMove.getNewPos().toString().equals("d8"))); + } + + @Test + void testPromotion() { + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + n k \s + PP \s + \s + \s + \s + \s + p \s + B K \s"""); // one white pawn can promote either by going forward or capturing a knight; black pawn can't promote + Function promoter = Queen::new; // always promote to a queen + chessBoardAndMoveStack.setPawnPromoter(promoter); + + Collection b7Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("b7")); + assertEquals(0, b7Moves.size()); + + Collection e2Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("e2")); + assertEquals(0, e2Moves.size()); + + Collection c7Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("c7")); + assertEquals(2, c7Moves.size()); + assertTrue(c7Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof PawnPromotionMove pawnPromotionMove + && pawnPromotionMove.getOldPos().toString().equals("c7") + && pawnPromotionMove.getNewPos().toString().equals("c8") + && pawnPromotionMove.getPawnPromoter() == promoter)); + assertTrue(c7Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof PawnPromotionMove pawnPromotionMove + && pawnPromotionMove.getOldPos().toString().equals("c7") + && pawnPromotionMove.getNewPos().toString().equals("b8") + && pawnPromotionMove.getPawnPromoter() == promoter)); + } + + @Test + void testEnPassant() { + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + k \s + pppppppp + \s + K P \s + \s + \s + \s + \s"""); // if c7 or e7 pawn double-doneMoves, it can get captured by d5 pawn en passant + chessBoardAndMoveStack.getPiece(Position.valueOf("d5")).setNeverMoved(false); + Collection c7Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("c7")); + assertEquals(2, c7Moves.size()); + assertTrue(c7Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("c7") + && simpleMove.getNewPos().toString().equals("c6"))); + assertTrue(c7Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("c7") + && simpleMove.getNewPos().toString().equals("c5"))); + + @SuppressWarnings("OptionalGetWithoutIsPresent") // we already found it before + MoveEntry pawn_c5 = c7Moves.stream() + .filter(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("c7") + && simpleMove.getNewPos().toString().equals("c5")) + .findAny().get(); + chessBoardAndMoveStack.perform(pawn_c5.move()); + assertTrue(ChessUtil.wasPawnDoubleMove(pawn_c5.move(), Position.valueOf("c5"), PieceColor.BLACK)); + + Collection d5Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("d5")); + assertEquals(2, d5Moves.size()); + assertTrue(d5Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("d5") + && simpleMove.getNewPos().toString().equals("d6"))); + assertTrue(d5Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof EnPassantMove enPassantMove + && enPassantMove.getOldPos().toString().equals("d5") + && !enPassantMove.isRight())); + } + + @Test + void testEnPassantFails() { + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + k \s + pp ppppp + \s + K pP \s + \s + \s + \s + \s"""); // the pawn on c5 e.g. moved from c6 or was already like that for whatever reason + chessBoardAndMoveStack.getPiece(Position.valueOf("d5")).setNeverMoved(false); + chessBoardAndMoveStack.getPiece(Position.valueOf("c5")).setNeverMoved(false); + Collection d5Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("d5")); + assertEquals(1, d5Moves.size()); + assertTrue(d5Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("d5") + && simpleMove.getNewPos().toString().equals("d6"))); + } + + @Test + void testNotCheckmate1() { + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + R k \s + \s + K \s + q \s + \s + \s + \s + \s"""); // the queen on a5 can either take the rook on a8 or block its attack by going to d8 + assertFalse(chessBoardAndMoveStack.isCheckmate(PieceColor.BLACK)); + assertTrue(chessBoardAndMoveStack.isCheck(PieceColor.BLACK)); + + Collection e8Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("e8")); + assertEquals(0, e8Moves.size()); + + Collection a5Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("a5")); + assertEquals(2, a5Moves.size()); + assertTrue(a5Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("a5") + && simpleMove.getNewPos().toString().equals("a8"))); + assertTrue(a5Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("a5") + && simpleMove.getNewPos().toString().equals("d8"))); + } + + @Test + void testCheckmate() { + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + k \s + Q \s + K \s + n \s + \s + \s + \s + \s"""); + assertTrue(chessBoardAndMoveStack.isCheckmate(PieceColor.BLACK)); + + Collection e8Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("e8")); + assertEquals(0, e8Moves.size()); + + Collection a5Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("a5")); + assertEquals(0, a5Moves.size()); + } + + @Test + void testNotCheckmate2() { + @SuppressWarnings("EscapedSpace") // required for each line to be 8 chars + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + k \s + Q \s + \s + K \s + \s \s + \s + \s + \s"""); + assertFalse(chessBoardAndMoveStack.isCheckmate(PieceColor.BLACK)); + assertTrue(chessBoardAndMoveStack.isCheck(PieceColor.BLACK)); + + Collection e8Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("e8")); + assertEquals(1, e8Moves.size()); + assertTrue(e8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("e8") + && simpleMove.getNewPos().toString().equals("e7"))); + } + + @Test + void testStalemate() { + @SuppressWarnings("EscapedSpace") // required for each line to be 8 chars + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + k \s + P \s + K \s + \s + \s \s + \s + \s + \s"""); // black king can't move but not in check + chessBoardAndMoveStack.setPawnPromoter(Queen::new); + assertFalse(chessBoardAndMoveStack.isCheck(PieceColor.BLACK)); + assertTrue(chessBoardAndMoveStack.isStalemate(PieceColor.BLACK)); + } + + @Test + void testNotStalemate() { + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + k \s + P \s + K \s + \s + p \s + \s + \s + \s"""); // black king can't move but not in check + chessBoardAndMoveStack.getPiece(Position.valueOf("a4")).setNeverMoved(false); + chessBoardAndMoveStack.setPawnPromoter(Queen::new); + + assertFalse(chessBoardAndMoveStack.isCheck(PieceColor.BLACK)); + assertFalse(chessBoardAndMoveStack.isStalemate(PieceColor.BLACK)); + + Collection e8Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("e8")); + assertEquals(0, e8Moves.size()); + + Collection a4Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("a4")); + assertEquals(1, a4Moves.size()); + assertTrue(a4Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("a4") + && simpleMove.getNewPos().toString().equals("a3"))); + } + + @Test + void testAmbiguousMovesNotation() { + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + R \s + \s + K \s + \s + k \s + \s + \s + R R"""); + // if one of the rooks doneMoves to a field on the same column, e.g. Ra5, it would be ambiguous. + // It should be R1a5 or R8a5 + Collection a8Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("a8")); + assertTrue(a8Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("a8") + && simpleMove.getNewPos().toString().equals("a5"))); + @SuppressWarnings("OptionalGetWithoutIsPresent") // we already know it's present + MoveEntry rook_8_a5 = a8Moves.stream().filter(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("a8") + && simpleMove.getNewPos().toString().equals("a5")) + .findAny().get(); + chessBoardAndMoveStack.perform(rook_8_a5.move()); + assertEquals("R8a5", rook_8_a5.move().getNotationRepresentation(chessBoardAndMoveStack.getChessBoard(), chessBoardAndMoveStack.getMoveStack())); + chessBoardAndMoveStack.undo(); + + Collection a1Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("a1")); + assertTrue(a1Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("a1") + && simpleMove.getNewPos().toString().equals("a5"))); + @SuppressWarnings("OptionalGetWithoutIsPresent") // we already know it's present + MoveEntry rook_1_a5 = a1Moves.stream().filter(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("a1") + && simpleMove.getNewPos().toString().equals("a5")) + .findAny().get(); + chessBoardAndMoveStack.perform(rook_1_a5.move()); + assertEquals("R1a5", rook_1_a5.move().getNotationRepresentation(chessBoardAndMoveStack.getChessBoard(), chessBoardAndMoveStack.getMoveStack())); + chessBoardAndMoveStack.undo(); + + assertTrue(a1Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("a1") + && simpleMove.getNewPos().toString().equals("e1"))); + @SuppressWarnings("OptionalGetWithoutIsPresent") // we already know it's present + MoveEntry rook_a_e1 = a1Moves.stream().filter(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("a1") + && simpleMove.getNewPos().toString().equals("e1")) + .findAny().get(); + chessBoardAndMoveStack.perform(rook_a_e1.move()); + assertEquals("Rae1", rook_a_e1.move().getNotationRepresentation(chessBoardAndMoveStack.getChessBoard(), chessBoardAndMoveStack.getMoveStack())); + chessBoardAndMoveStack.undo(); + + Collection h1Moves = chessBoardAndMoveStack.getMoves(Position.valueOf("h1")); + assertTrue(h1Moves.stream().anyMatch(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("h1") + && simpleMove.getNewPos().toString().equals("e1"))); + @SuppressWarnings("OptionalGetWithoutIsPresent") // we already know it's present + MoveEntry rook_h_e1 = h1Moves.stream().filter(moveEntry -> moveEntry.move() instanceof SimpleMove simpleMove + && simpleMove.getOldPos().toString().equals("h1") + && simpleMove.getNewPos().toString().equals("e1")) + .findAny().get(); + chessBoardAndMoveStack.perform(rook_h_e1.move()); + assertEquals("Rhe1", rook_h_e1.move().getNotationRepresentation(chessBoardAndMoveStack.getChessBoard(), chessBoardAndMoveStack.getMoveStack())); + } + + @Test + void testFEN1() { + ChessBoardAndMoveStack chessBoardAndMoveStack = ChessBoardAndMoveStack.startPosition(); + assertEquals("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", chessBoardAndMoveStack.toFEN()); + chessBoardAndMoveStack.reverseCurrentlyMoving(); + assertEquals("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR b KQkq - 0 1", chessBoardAndMoveStack.toFEN()); + } + + @Test + void testFEN2() { + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + r k r + \s + \s + \s + \s + \s + \s + R K R"""); + assertEquals("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1", chessBoardAndMoveStack.toFEN()); + chessBoardAndMoveStack.reverseCurrentlyMoving(); + assertEquals("r3k2r/8/8/8/8/8/8/R3K2R b KQkq - 0 1", chessBoardAndMoveStack.toFEN()); + } + + @Test + void testUCIMoveParserOnSimpleMove() { + ChessBoardAndMoveStack chessBoardAndMoveStack = ChessBoardAndMoveStack.startPosition(); + SimpleMove simpleMove = assertInstanceOf(SimpleMove.class, ChessUtil.parseUCIMove("e2e4", chessBoardAndMoveStack)); + assertEquals(Position.valueOf("e2"), simpleMove.getOldPos()); + assertEquals(Position.valueOf("e4"), simpleMove.getNewPos()); + } + + @Test + void testUCIMoveParserOnCastling() { + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + r k r + \s + \s + \s + \s + \s + \s + R K R"""); + CastlingMove castlingMove = assertInstanceOf(CastlingMove.class, ChessUtil.parseUCIMove("e1g1", chessBoardAndMoveStack)); + assertEquals(Position.valueOf("e1"), castlingMove.getKingOldPos()); + assertEquals(Position.valueOf("h1"), castlingMove.getRookOldPos()); + } + + @Test + void testUCIMoveParserOnPawnPromotion() { + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + n k \s + PP \s + \s + \s + \s + \s + p \s + B K \s"""); + PawnPromotionMove pawnPromotionMove = assertInstanceOf(PawnPromotionMove.class, ChessUtil.parseUCIMove("c7c8q", chessBoardAndMoveStack)); + assertEquals(Position.valueOf("c7"), pawnPromotionMove.getOldPos()); + assertEquals(Position.valueOf("c8"), pawnPromotionMove.getNewPos()); + assertInstanceOf(Queen.class, pawnPromotionMove.getPawnPromoter().apply(PieceColor.WHITE)); + + pawnPromotionMove = assertInstanceOf(PawnPromotionMove.class, ChessUtil.parseUCIMove("c7b8r", chessBoardAndMoveStack)); + assertEquals(Position.valueOf("c7"), pawnPromotionMove.getOldPos()); + assertEquals(Position.valueOf("b8"), pawnPromotionMove.getNewPos()); + assertInstanceOf(Rook.class, pawnPromotionMove.getPawnPromoter().apply(PieceColor.WHITE)); + } + + @Test + void testUCIMoveParserOnEnPassant() { + ChessBoardAndMoveStack chessBoardAndMoveStack = fromString(""" + k \s + pp ppppp + \s + K pP \s + \s + \s + \s + \s"""); + EnPassantMove enPassantMove = assertInstanceOf(EnPassantMove.class, ChessUtil.parseUCIMove("d5c6", chessBoardAndMoveStack)); + assertEquals(Position.valueOf("d5"), enPassantMove.getOldPos()); + assertFalse(enPassantMove.isRight()); + } +}