revert(docs): Removed created docs from claude for en passant
Build & Test (NowChessSystems) TeamCity build finished

Removed created docs from claude for en passant and added them in the correct repo
This commit is contained in:
LQ63
2026-03-29 16:06:54 +02:00
parent b684179d33
commit a0d20a9975
2 changed files with 0 additions and 600 deletions
@@ -1,466 +0,0 @@
# En Passant Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement en passant capture so a pawn that has just made a double push can be captured by an adjacent enemy pawn on the next move.
**Architecture:** A new `EnPassantCalculator` object derives the en passant target square from the last `HistoryMove` in `GameHistory`, mirroring `CastlingRightsCalculator`. `MoveValidator.legalTargets(board, history, from)` is extended to include the en passant square in pawn targets. `GameController.processMove` detects en passant and calls `board.removed` to remove the captured pawn.
**Tech Stack:** Scala 3.5.x, ScalaTest AnyFunSuite + Matchers, scoverage 100% line/branch/method.
---
## File Map
| Action | File |
|--------|------|
| Create | `modules/core/src/main/scala/de/nowchess/chess/logic/EnPassantCalculator.scala` |
| Create | `modules/core/src/test/scala/de/nowchess/chess/logic/EnPassantCalculatorTest.scala` |
| Modify | `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala` |
| Modify | `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` |
| Modify | `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` |
| Modify | `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` |
---
## Task 1: EnPassantCalculator — all three methods
**Files:**
- Create: `modules/core/src/main/scala/de/nowchess/chess/logic/EnPassantCalculator.scala`
- Create: `modules/core/src/test/scala/de/nowchess/chess/logic/EnPassantCalculatorTest.scala`
- [ ] **Step 1: Write the failing tests**
Create `modules/core/src/test/scala/de/nowchess/chess/logic/EnPassantCalculatorTest.scala`:
```scala
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class EnPassantCalculatorTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
private def board(entries: (Square, Piece)*): Board = Board(entries.toMap)
// ──── enPassantTarget ────────────────────────────────────────────────
test("enPassantTarget returns None for empty history"):
val b = board(sq(File.E, Rank.R4) -> Piece.WhitePawn)
EnPassantCalculator.enPassantTarget(b, GameHistory.empty) shouldBe None
test("enPassantTarget returns None when last move was a single pawn push"):
val b = board(sq(File.E, Rank.R3) -> Piece.WhitePawn)
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R3))
EnPassantCalculator.enPassantTarget(b, h) shouldBe None
test("enPassantTarget returns None when last move was not a pawn"):
val b = board(sq(File.E, Rank.R4) -> Piece.WhiteRook)
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
EnPassantCalculator.enPassantTarget(b, h) shouldBe None
test("enPassantTarget returns e3 after white pawn double push e2-e4"):
val b = board(sq(File.E, Rank.R4) -> Piece.WhitePawn)
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.E, Rank.R3))
test("enPassantTarget returns e6 after black pawn double push e7-e5"):
val b = board(sq(File.E, Rank.R5) -> Piece.BlackPawn)
val h = GameHistory.empty.addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.E, Rank.R6))
test("enPassantTarget returns d3 after white pawn double push d2-d4"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhitePawn)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.D, Rank.R3))
// ──── capturedPawnSquare ─────────────────────────────────────────────
test("capturedPawnSquare for white capturing on e6 returns e5"):
EnPassantCalculator.capturedPawnSquare(sq(File.E, Rank.R6), Color.White) shouldBe sq(File.E, Rank.R5)
test("capturedPawnSquare for black capturing on e3 returns e4"):
EnPassantCalculator.capturedPawnSquare(sq(File.E, Rank.R3), Color.Black) shouldBe sq(File.E, Rank.R4)
test("capturedPawnSquare for white capturing on d6 returns d5"):
EnPassantCalculator.capturedPawnSquare(sq(File.D, Rank.R6), Color.White) shouldBe sq(File.D, Rank.R5)
// ──── isEnPassant ────────────────────────────────────────────────────
test("isEnPassant returns true for valid white en passant capture"):
// White pawn on e5, black pawn just double-pushed to d5 (ep target = d6)
val b = board(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe true
test("isEnPassant returns true for valid black en passant capture"):
// Black pawn on d4, white pawn just double-pushed to e4 (ep target = e3)
val b = board(
sq(File.D, Rank.R4) -> Piece.BlackPawn,
sq(File.E, Rank.R4) -> Piece.WhitePawn
)
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
EnPassantCalculator.isEnPassant(b, h, sq(File.D, Rank.R4), sq(File.E, Rank.R3)) shouldBe true
test("isEnPassant returns false when no en passant target in history"):
val b = board(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) // single push
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
test("isEnPassant returns false when piece at from is not a pawn"):
val b = board(
sq(File.E, Rank.R5) -> Piece.WhiteRook,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
test("isEnPassant returns false when to does not match ep target"):
val b = board(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.E, Rank.R6)) shouldBe false
test("isEnPassant returns false when from square is empty"):
val b = board(sq(File.D, Rank.R5) -> Piece.BlackPawn)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
```
- [ ] **Step 2: Run tests and confirm they fail**
```bash
cd C:/AIN-Festplatte/Softwarearchitekturen/NowChessSystems
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.EnPassantCalculatorTest"
```
Expected: compilation error — `EnPassantCalculator` does not exist yet.
- [ ] **Step 3: Create `EnPassantCalculator.scala`**
Create `modules/core/src/main/scala/de/nowchess/chess/logic/EnPassantCalculator.scala`:
```scala
package de.nowchess.chess.logic
import de.nowchess.api.board.*
object EnPassantCalculator:
/** Returns the en passant target square if the last move was a double pawn push.
* The target is the square the pawn passed through (e.g. e2e4 yields e3).
*/
def enPassantTarget(board: Board, history: GameHistory): Option[Square] =
history.moves.lastOption.flatMap: move =>
val rankDiff = move.to.rank.ordinal - move.from.rank.ordinal
val isDoublePush = math.abs(rankDiff) == 2
val isPawn = board.pieceAt(move.to).exists(_.pieceType == PieceType.Pawn)
if isDoublePush && isPawn then
val midRankIdx = move.from.rank.ordinal + rankDiff / 2
Some(Square(move.to.file, Rank.values(midRankIdx)))
else None
/** True if moving from→to is an en passant capture. */
def isEnPassant(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
board.pieceAt(from).exists(_.pieceType == PieceType.Pawn) &&
enPassantTarget(board, history).contains(to) &&
math.abs(to.file.ordinal - from.file.ordinal) == 1
/** Returns the square of the pawn to remove when an en passant capture lands on `to`.
* White captures upward → captured pawn is one rank below `to`.
* Black captures downward → captured pawn is one rank above `to`.
*/
def capturedPawnSquare(to: Square, color: Color): Square =
val capturedRankIdx = to.rank.ordinal + (if color == Color.White then -1 else 1)
Square(to.file, Rank.values(capturedRankIdx))
```
- [ ] **Step 4: Run tests and confirm they pass**
```bash
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.EnPassantCalculatorTest"
```
Expected: all 14 tests GREEN.
- [ ] **Step 5: Commit**
```bash
cd C:/AIN-Festplatte/Softwarearchitekturen/NowChessSystems
git add modules/core/src/main/scala/de/nowchess/chess/logic/EnPassantCalculator.scala \
modules/core/src/test/scala/de/nowchess/chess/logic/EnPassantCalculatorTest.scala
git commit -m "feat: NCS-9 add EnPassantCalculator with target derivation and capture logic"
```
---
## Task 2: MoveValidator — history-aware pawn targets
**Files:**
- Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala:154-162`
- Modify: `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala`
- [ ] **Step 1: Write the failing tests**
Append to `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` (before the final blank line):
```scala
// ──── Pawn en passant targets ──────────────────────────────────────
test("white pawn includes ep target in legal moves after black double push"):
// Black pawn just double-pushed to d5 (ep target = d6); white pawn on e5
val b = board(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
MoveValidator.legalTargets(b, h, sq(File.E, Rank.R5)) should contain(sq(File.D, Rank.R6))
test("white pawn does not include ep target without a preceding double push"):
val b = board(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) // single push
MoveValidator.legalTargets(b, h, sq(File.E, Rank.R5)) should not contain sq(File.D, Rank.R6)
test("black pawn includes ep target in legal moves after white double push"):
// White pawn just double-pushed to e4 (ep target = e3); black pawn on d4
val b = board(
sq(File.D, Rank.R4) -> Piece.BlackPawn,
sq(File.E, Rank.R4) -> Piece.WhitePawn
)
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R3))
test("pawn on wrong file does not get ep target from adjacent double push"):
// White pawn on a5, black pawn double-pushed to d5 — a5 is not adjacent to d5
val b = board(
sq(File.A, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
MoveValidator.legalTargets(b, h, sq(File.A, Rank.R5)) should not contain sq(File.D, Rank.R6)
```
- [ ] **Step 2: Run tests and confirm they fail**
```bash
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest"
```
Expected: the 4 new tests FAIL (ep target is never included by current `legalTargets`).
- [ ] **Step 3: Update `MoveValidator.legalTargets(board, history, from)`**
In `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala`, replace lines 154162:
```scala
def legalTargets(board: Board, history: GameHistory, from: Square): Set[Square] =
board.pieceAt(from) match
case Some(piece) if piece.pieceType == PieceType.King =>
legalTargets(board, from) ++ castlingTargets(board, history, piece.color)
case _ =>
legalTargets(board, from)
```
with:
```scala
def legalTargets(board: Board, history: GameHistory, from: Square): Set[Square] =
board.pieceAt(from) match
case Some(piece) if piece.pieceType == PieceType.King =>
legalTargets(board, from) ++ castlingTargets(board, history, piece.color)
case Some(piece) if piece.pieceType == PieceType.Pawn =>
pawnTargets(board, history, from, piece.color)
case _ =>
legalTargets(board, from)
private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square] =
val existing = pawnTargets(board, from, color)
val fi = from.file.ordinal
val ri = from.rank.ordinal
val dir = if color == Color.White then 1 else -1
val epCapture: Set[Square] =
EnPassantCalculator.enPassantTarget(board, history).filter: target =>
squareAt(fi - 1, ri + dir).contains(target) || squareAt(fi + 1, ri + dir).contains(target)
.toSet
existing ++ epCapture
```
No import needed — `EnPassantCalculator` is in the same package (`de.nowchess.chess.logic`) as `MoveValidator`.
- [ ] **Step 4: Run tests and confirm they pass**
```bash
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest"
```
Expected: all tests GREEN including the 4 new ones.
- [ ] **Step 5: Commit**
```bash
cd C:/AIN-Festplatte/Softwarearchitekturen/NowChessSystems
git add modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala \
modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala
git commit -m "feat: NCS-9 include en passant square in pawn legal targets"
```
---
## Task 3: GameController — en passant board mutation
**Files:**
- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala:51-56`
- Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala`
- [ ] **Step 1: Write the failing tests**
Read `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` first to understand existing test structure, then append:
```scala
test("en passant capture removes the captured pawn from the board"):
// Setup: white pawn e5, black pawn just double-pushed to d5 (ep target = d6)
val b = Board(Map(
Square(File.E, Rank.R5) -> Piece.WhitePawn,
Square(File.D, Rank.R5) -> Piece.BlackPawn,
Square(File.E, Rank.R1) -> Piece.WhiteKing,
Square(File.E, Rank.R8) -> Piece.BlackKing
))
val h = GameHistory.empty.addMove(Square(File.D, Rank.R7), Square(File.D, Rank.R5))
val result = GameController.processMove(b, h, Color.White, "e5d6")
result match
case MoveResult.Moved(newBoard, _, captured, _) =>
newBoard.pieceAt(Square(File.D, Rank.R5)) shouldBe None // captured pawn removed
newBoard.pieceAt(Square(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) // capturing pawn placed
captured shouldBe Some(Piece.BlackPawn)
case other => fail(s"Expected Moved but got $other")
test("en passant capture by black removes the captured white pawn"):
// Setup: black pawn d4, white pawn just double-pushed to e4 (ep target = e3)
val b = Board(Map(
Square(File.D, Rank.R4) -> Piece.BlackPawn,
Square(File.E, Rank.R4) -> Piece.WhitePawn,
Square(File.E, Rank.R8) -> Piece.BlackKing,
Square(File.E, Rank.R1) -> Piece.WhiteKing
))
val h = GameHistory.empty.addMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val result = GameController.processMove(b, h, Color.Black, "d4e3")
result match
case MoveResult.Moved(newBoard, _, captured, _) =>
newBoard.pieceAt(Square(File.E, Rank.R4)) shouldBe None // captured pawn removed
newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed
captured shouldBe Some(Piece.WhitePawn)
case other => fail(s"Expected Moved but got $other")
```
- [ ] **Step 2: Run tests and confirm they fail**
```bash
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest"
```
Expected: the 2 new tests FAIL — captured pawn is not removed (board still contains the double-pushed pawn at d5/e4).
- [ ] **Step 3: Update `GameController.processMove`**
In `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`, replace lines 5156:
```scala
val castleOpt = if MoveValidator.isCastle(board, from, to)
then Some(MoveValidator.castleSide(from, to))
else None
val (newBoard, captured) = castleOpt match
case Some(side) => (board.withCastle(turn, side), None)
case None => board.withMove(from, to)
```
with:
```scala
val castleOpt = if MoveValidator.isCastle(board, from, to)
then Some(MoveValidator.castleSide(from, to))
else None
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
val (newBoard, captured) = castleOpt match
case Some(side) => (board.withCastle(turn, side), None)
case None =>
val (b, cap) = board.withMove(from, to)
if isEP then
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
(b.removed(capturedSq), board.pieceAt(capturedSq))
else (b, cap)
```
Also add the import at line 6 (after the existing `import de.nowchess.chess.logic.*`):
The wildcard import `de.nowchess.chess.logic.*` already covers `EnPassantCalculator` — no new import needed.
- [ ] **Step 4: Run tests and confirm they pass**
```bash
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest"
```
Expected: all tests GREEN including the 2 new ones.
- [ ] **Step 5: Run full test suite**
```bash
./gradlew :modules:core:test
```
Expected: all tests GREEN.
- [ ] **Step 6: Commit**
```bash
cd C:/AIN-Festplatte/Softwarearchitekturen/NowChessSystems
git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala \
modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala
git commit -m "feat: NCS-9 apply en passant board mutation in GameController"
```
---
## Task 4: Coverage verification
**Files:**
- No code changes — verify scoverage and fix any gaps.
- [ ] **Step 1: Run scoverage**
```bash
cd C:/AIN-Festplatte/Softwarearchitekturen/NowChessSystems
./gradlew :modules:core:scoverageTest
```
- [ ] **Step 2: Check coverage gaps**
```bash
python jacoco-reporter/scoverage_coverage_gaps.py modules/core/build/reports/scoverageTest/scoverage.xml
```
Expected: no gaps in `EnPassantCalculator`, `MoveValidator`, or `GameController`. If gaps are reported, add targeted tests to the relevant test file, re-run scoverage, and repeat until clean.
- [ ] **Step 3: Commit if any tests were added**
Only commit if additional tests were needed:
```bash
git add modules/core/src/test/scala/de/nowchess/chess/logic/EnPassantCalculatorTest.scala \
modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala \
modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala
git commit -m "test: NCS-9 fill coverage gaps for en passant"
```
@@ -1,134 +0,0 @@
# 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`.