From 1ce62d4bdba6856fb5e065c111bd32ba38be189c Mon Sep 17 00:00:00 2001 From: LQ63 Date: Sun, 29 Mar 2026 15:32:58 +0200 Subject: [PATCH] feat: NCS-9 include en passant square in pawn legal targets --- .../nowchess/chess/logic/MoveValidator.scala | 13 +++++++ .../chess/logic/MoveValidatorTest.scala | 37 +++++++++++++++++++ 2 files changed, 50 insertions(+) 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/logic/MoveValidatorTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala index f47fec2..be1829a 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,40 @@ 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)