diff --git a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala index acb7d17..4717430 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala @@ -51,9 +51,15 @@ object GameController: val castleOpt = if MoveValidator.isCastle(board, from, to) then Some(MoveValidator.castleSide(from, to)) else None + val isEP = EnPassantCalculator.isEnPassant(board, history, from, to) val (newBoard, captured) = castleOpt match case Some(side) => (board.withCastle(turn, side), None) - case None => board.withMove(from, to) + case None => + val (b, cap) = board.withMove(from, to) + if isEP then + val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn) + (b.removed(capturedSq), board.pieceAt(capturedSq)) + else (b, cap) val newHistory = history.addMove(from, to, castleOpt) GameRules.gameStatus(newBoard, newHistory, turn.opposite) match case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite) diff --git a/modules/core/src/main/scala/de/nowchess/chess/logic/EnPassantCalculator.scala b/modules/core/src/main/scala/de/nowchess/chess/logic/EnPassantCalculator.scala new file mode 100644 index 0000000..88e6212 --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/logic/EnPassantCalculator.scala @@ -0,0 +1,32 @@ +package de.nowchess.chess.logic + +import de.nowchess.api.board.* + +object EnPassantCalculator: + + /** Returns the en passant target square if the last move was a double pawn push. + * The target is the square the pawn passed through (e.g. e2→e4 yields e3). + */ + def enPassantTarget(board: Board, history: GameHistory): Option[Square] = + history.moves.lastOption.flatMap: move => + val rankDiff = move.to.rank.ordinal - move.from.rank.ordinal + val isDoublePush = math.abs(rankDiff) == 2 + val isPawn = board.pieceAt(move.to).exists(_.pieceType == PieceType.Pawn) + if isDoublePush && isPawn then + val midRankIdx = move.from.rank.ordinal + rankDiff / 2 + Some(Square(move.to.file, Rank.values(midRankIdx))) + else None + + /** True if moving from→to is an en passant capture. */ + def isEnPassant(board: Board, history: GameHistory, from: Square, to: Square): Boolean = + board.pieceAt(from).exists(_.pieceType == PieceType.Pawn) && + enPassantTarget(board, history).contains(to) && + math.abs(to.file.ordinal - from.file.ordinal) == 1 + + /** Returns the square of the pawn to remove when an en passant capture lands on `to`. + * White captures upward → captured pawn is one rank below `to`. + * Black captures downward → captured pawn is one rank above `to`. + */ + def capturedPawnSquare(to: Square, color: Color): Square = + val capturedRankIdx = to.rank.ordinal + (if color == Color.White then -1 else 1) + Square(to.file, Rank.values(capturedRankIdx)) diff --git a/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala b/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala index e40859e..22a8eee 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala @@ -155,8 +155,21 @@ object MoveValidator: board.pieceAt(from) match case Some(piece) if piece.pieceType == PieceType.King => legalTargets(board, from) ++ castlingTargets(board, history, piece.color) + case Some(piece) if piece.pieceType == PieceType.Pawn => + pawnTargets(board, history, from, piece.color) case _ => legalTargets(board, from) + private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square] = + val existing = pawnTargets(board, from, color) + val fi = from.file.ordinal + val ri = from.rank.ordinal + val dir = if color == Color.White then 1 else -1 + val epCapture: Set[Square] = + EnPassantCalculator.enPassantTarget(board, history).filter: target => + squareAt(fi - 1, ri + dir).contains(target) || squareAt(fi + 1, ri + dir).contains(target) + .toSet + existing ++ epCapture + def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean = legalTargets(board, history, from).contains(to) diff --git a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala index 5e1a71e..8124005 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala @@ -365,3 +365,39 @@ class GameControllerTest extends AnyFunSuite with Matchers: case MoveResult.MovedInCheck(_, newHistory, _, _) => castlingRights(newHistory, Color.White).queenSide shouldBe false case other => fail(s"Expected Moved or MovedInCheck, got $other") + + // ──── en passant ──────────────────────────────────────────────────────── + + test("en passant capture removes the captured pawn from the board"): + // Setup: white pawn e5, black pawn just double-pushed to d5 (ep target = d6) + val b = Board(Map( + Square(File.E, Rank.R5) -> Piece.WhitePawn, + Square(File.D, Rank.R5) -> Piece.BlackPawn, + Square(File.E, Rank.R1) -> Piece.WhiteKing, + Square(File.E, Rank.R8) -> Piece.BlackKing + )) + val h = GameHistory.empty.addMove(Square(File.D, Rank.R7), Square(File.D, Rank.R5)) + val result = GameController.processMove(b, h, Color.White, "e5d6") + result match + case MoveResult.Moved(newBoard, _, captured, _) => + newBoard.pieceAt(Square(File.D, Rank.R5)) shouldBe None // captured pawn removed + newBoard.pieceAt(Square(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) // capturing pawn placed + captured shouldBe Some(Piece.BlackPawn) + case other => fail(s"Expected Moved but got $other") + + test("en passant capture by black removes the captured white pawn"): + // Setup: black pawn d4, white pawn just double-pushed to e4 (ep target = e3) + val b = Board(Map( + Square(File.D, Rank.R4) -> Piece.BlackPawn, + Square(File.E, Rank.R4) -> Piece.WhitePawn, + Square(File.E, Rank.R8) -> Piece.BlackKing, + Square(File.E, Rank.R1) -> Piece.WhiteKing + )) + val h = GameHistory.empty.addMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) + val result = GameController.processMove(b, h, Color.Black, "d4e3") + result match + case MoveResult.Moved(newBoard, _, captured, _) => + newBoard.pieceAt(Square(File.E, Rank.R4)) shouldBe None // captured pawn removed + newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed + captured shouldBe Some(Piece.WhitePawn) + case other => fail(s"Expected Moved but got $other") diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/EnPassantCalculatorTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/EnPassantCalculatorTest.scala new file mode 100644 index 0000000..31963f5 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/logic/EnPassantCalculatorTest.scala @@ -0,0 +1,101 @@ +package de.nowchess.chess.logic + +import de.nowchess.api.board.* +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class EnPassantCalculatorTest extends AnyFunSuite with Matchers: + + private def sq(f: File, r: Rank): Square = Square(f, r) + private def board(entries: (Square, Piece)*): Board = Board(entries.toMap) + + // ──── enPassantTarget ──────────────────────────────────────────────── + + test("enPassantTarget returns None for empty history"): + val b = board(sq(File.E, Rank.R4) -> Piece.WhitePawn) + EnPassantCalculator.enPassantTarget(b, GameHistory.empty) shouldBe None + + test("enPassantTarget returns None when last move was a single pawn push"): + val b = board(sq(File.E, Rank.R3) -> Piece.WhitePawn) + val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R3)) + EnPassantCalculator.enPassantTarget(b, h) shouldBe None + + test("enPassantTarget returns None when last move was not a pawn"): + val b = board(sq(File.E, Rank.R4) -> Piece.WhiteRook) + val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + EnPassantCalculator.enPassantTarget(b, h) shouldBe None + + test("enPassantTarget returns e3 after white pawn double push e2-e4"): + val b = board(sq(File.E, Rank.R4) -> Piece.WhitePawn) + val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.E, Rank.R3)) + + test("enPassantTarget returns e6 after black pawn double push e7-e5"): + val b = board(sq(File.E, Rank.R5) -> Piece.BlackPawn) + val h = GameHistory.empty.addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) + EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.E, Rank.R6)) + + test("enPassantTarget returns d3 after white pawn double push d2-d4"): + val b = board(sq(File.D, Rank.R4) -> Piece.WhitePawn) + val h = GameHistory.empty.addMove(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) + EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.D, Rank.R3)) + + // ──── capturedPawnSquare ───────────────────────────────────────────── + + test("capturedPawnSquare for white capturing on e6 returns e5"): + EnPassantCalculator.capturedPawnSquare(sq(File.E, Rank.R6), Color.White) shouldBe sq(File.E, Rank.R5) + + test("capturedPawnSquare for black capturing on e3 returns e4"): + EnPassantCalculator.capturedPawnSquare(sq(File.E, Rank.R3), Color.Black) shouldBe sq(File.E, Rank.R4) + + test("capturedPawnSquare for white capturing on d6 returns d5"): + EnPassantCalculator.capturedPawnSquare(sq(File.D, Rank.R6), Color.White) shouldBe sq(File.D, Rank.R5) + + // ──── isEnPassant ──────────────────────────────────────────────────── + + test("isEnPassant returns true for valid white en passant capture"): + // White pawn on e5, black pawn just double-pushed to d5 (ep target = d6) + val b = board( + sq(File.E, Rank.R5) -> Piece.WhitePawn, + sq(File.D, Rank.R5) -> Piece.BlackPawn + ) + val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5)) + EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe true + + test("isEnPassant returns true for valid black en passant capture"): + // Black pawn on d4, white pawn just double-pushed to e4 (ep target = e3) + val b = board( + sq(File.D, Rank.R4) -> Piece.BlackPawn, + sq(File.E, Rank.R4) -> Piece.WhitePawn + ) + val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + EnPassantCalculator.isEnPassant(b, h, sq(File.D, Rank.R4), sq(File.E, Rank.R3)) shouldBe true + + test("isEnPassant returns false when no en passant target in history"): + val b = board( + sq(File.E, Rank.R5) -> Piece.WhitePawn, + sq(File.D, Rank.R5) -> Piece.BlackPawn + ) + val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) // single push + EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false + + test("isEnPassant returns false when piece at from is not a pawn"): + val b = board( + sq(File.E, Rank.R5) -> Piece.WhiteRook, + sq(File.D, Rank.R5) -> Piece.BlackPawn + ) + val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5)) + EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false + + test("isEnPassant returns false when to does not match ep target"): + val b = board( + sq(File.E, Rank.R5) -> Piece.WhitePawn, + sq(File.D, Rank.R5) -> Piece.BlackPawn + ) + val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5)) + EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.E, Rank.R6)) shouldBe false + + test("isEnPassant returns false when from square is empty"): + val b = board(sq(File.D, Rank.R5) -> Piece.BlackPawn) + val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5)) + EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala index f47fec2..6c819dd 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala @@ -211,3 +211,47 @@ class MoveValidatorTest extends AnyFunSuite with Matchers: sq(File.E, Rank.R4) -> Piece.BlackRook ) MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R4)) + + // ──── Pawn – en passant targets ────────────────────────────────────── + + test("white pawn includes ep target in legal moves after black double push"): + // Black pawn just double-pushed to d5 (ep target = d6); white pawn on e5 + val b = board( + sq(File.E, Rank.R5) -> Piece.WhitePawn, + sq(File.D, Rank.R5) -> Piece.BlackPawn + ) + val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5)) + MoveValidator.legalTargets(b, h, sq(File.E, Rank.R5)) should contain(sq(File.D, Rank.R6)) + + test("white pawn does not include ep target without a preceding double push"): + val b = board( + sq(File.E, Rank.R5) -> Piece.WhitePawn, + sq(File.D, Rank.R5) -> Piece.BlackPawn + ) + val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) // single push + MoveValidator.legalTargets(b, h, sq(File.E, Rank.R5)) should not contain sq(File.D, Rank.R6) + + test("black pawn includes ep target in legal moves after white double push"): + // White pawn just double-pushed to e4 (ep target = e3); black pawn on d4 + val b = board( + sq(File.D, Rank.R4) -> Piece.BlackPawn, + sq(File.E, Rank.R4) -> Piece.WhitePawn + ) + val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R3)) + + test("pawn on wrong file does not get ep target from adjacent double push"): + // White pawn on a5, black pawn double-pushed to d5 — a5 is not adjacent to d5 + val b = board( + sq(File.A, Rank.R5) -> Piece.WhitePawn, + sq(File.D, Rank.R5) -> Piece.BlackPawn + ) + val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5)) + MoveValidator.legalTargets(b, h, sq(File.A, Rank.R5)) should not contain sq(File.D, Rank.R6) + + // ──── History-aware legalTargets fallback for non-pawn non-king pieces ───── + + test("legalTargets with history delegates to geometry-only for non-pawn non-king pieces"): + val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook) + val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) shouldBe MoveValidator.legalTargets(b, sq(File.D, Rank.R4))