docs: add en passant design spec for NCS-9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
LQ63
2026-03-29 14:48:09 +02:00
parent f28e69dc18
commit 02b75e8fac
@@ -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 e2e4 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`.