# En Passant — Design Spec **Date:** 2026-03-29 **Ticket:** NCS-9 **Status:** Approved --- ## Overview Implement en passant capture for the NowChessSystems chess engine. The data structures (`MoveType.EnPassant`, `GameState.enPassantTarget`) and FEN serialization are already in place. This spec covers the three missing pieces: target derivation, move generation, and board mutation. --- ## Architecture ### New component: `EnPassantCalculator` **File:** `modules/core/src/main/scala/de/nowchess/chess/logic/EnPassantCalculator.scala` Mirrors `CastlingRightsCalculator` in structure. Three methods: ```scala object EnPassantCalculator: /** Returns the en passant target square if the last move was a double pawn push. * e.g. last move e2→e4 yields target e3. */ def enPassantTarget(board: Board, history: GameHistory): Option[Square] /** True if moving from→to is a legal en passant capture. */ def isEnPassant(board: Board, history: GameHistory, from: Square, to: Square): Boolean /** Given the destination square and the moving color, returns the square * of the pawn to remove from the board. */ def capturedPawnSquare(to: Square, color: Color): Square ``` **`enPassantTarget` logic:** inspect the last `HistoryMove` in `GameHistory`; if the piece now at `to` is a pawn that moved exactly 2 ranks, the target is the square at `(to.file, midRank)` between `from` and `to`. **`isEnPassant` logic:** moving piece is a pawn, `to` equals `enPassantTarget(board, history)`, and the move is diagonal (file changes by 1). **`capturedPawnSquare` logic:** same file as `to`, one rank toward the moving pawn (White captures to rank 6 → captured pawn on rank 5; Black captures to rank 3 → captured pawn on rank 4). --- ### Change: `MoveValidator.pawnTargets` Add an overload accepting `GameHistory`: ```scala private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square] ``` This calls the existing geometry-only `pawnTargets(board, from, color)` and appends the en passant diagonal target when applicable: ```scala val epTarget = EnPassantCalculator.enPassantTarget(board, history) val epCapture: Set[Square] = epTarget.filter: target => val fi = from.file.ordinal val ri = from.rank.ordinal val dir = if color == Color.White then 1 else -1 squareAt(fi - 1, ri + dir).contains(target) || squareAt(fi + 1, ri + dir).contains(target) .toSet existing ++ epCapture ``` The context-aware `legalTargets(board, history, from)` calls this overload for pawns. --- ### Change: `GameController.processMove` After legality is confirmed, detect en passant and apply the correct board mutation: ```scala val isEP = EnPassantCalculator.isEnPassant(board, history, from, to) val newBoard = if isCastling then board.withCastle(turn, side) else val (b, _) = board.withMove(from, to) if isEP then b.removed(EnPassantCalculator.capturedPawnSquare(to, turn)) else b ``` `board.removed(sq)` already exists on `Board`. History entry is unchanged — en passant is recorded as `addMove(from, to)` with no castle side. --- ## Data Flow ``` User input: "e5d6" → Parser.parseMove → (e5, d6) → MoveValidator.isLegal(board, history, e5, d6) → pawnTargets(board, history, e5, White) → enPassantTarget(board, history) → Some(d6) → d6 is diagonal from e5 → included in targets → d6 ∈ legalTargets → legal → EnPassantCalculator.isEnPassant(board, history, e5, d6) → true → board.withMove(e5, d6) → pawn moves to d6 → board.removed(d5) → captured pawn removed → history.addMove(e5, d6) → GameRules.gameStatus(newBoard, newHistory, Black) ``` --- ## Error Handling No new error cases. En passant is only reachable via `legalTargets` — if `enPassantTarget` is `None` or the pawn is not adjacent, the square is simply not included in legal moves. Illegal en passant attempts are rejected by the existing legality check before `processMove` applies any mutation. --- ## Testing ### `EnPassantCalculatorTest` (unit) - `enPassantTarget` returns `None` for non-pawn last moves, non-double-push, and empty history - `enPassantTarget` returns correct square after White double push (e2→e4 → target e3) and Black (e7→e5 → target e6) - `isEnPassant` returns true for a valid en passant capture, false for normal captures, wrong piece, no ep target - `capturedPawnSquare` returns correct square for White and Black ### `MoveValidatorTest` (unit) - En passant target square appears in legal pawn targets after opponent double push - En passant target square does not appear without a preceding double push - En passant target disappears after a non-capturing move (history no longer shows double push) ### `GameRulesTest` (integration) - Full scenario: double push → en passant capture → captured pawn absent from board - En passant capture that exposes own king is correctly filtered as illegal ### Coverage 100% line / branch / method via scoverage, verified with `jacoco-reporter/scoverage_coverage_gaps.py`.