docs: add en passant design spec for NCS-9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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`.
|
||||
Reference in New Issue
Block a user