diff --git a/docs/superpowers/specs/2026-03-29-en-passant-design.md b/docs/superpowers/specs/2026-03-29-en-passant-design.md new file mode 100644 index 0000000..456d5d7 --- /dev/null +++ b/docs/superpowers/specs/2026-03-29-en-passant-design.md @@ -0,0 +1,134 @@ +# 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`.