Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5.0 KiB
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:
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:
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:
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:
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)
enPassantTargetreturnsNonefor non-pawn last moves, non-double-push, and empty historyenPassantTargetreturns correct square after White double push (e2→e4 → target e3) and Black (e7→e5 → target e6)isEnPassantreturns true for a valid en passant capture, false for normal captures, wrong piece, no ep targetcapturedPawnSquarereturns 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.