docs: add en passant implementation plan for NCS-9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
LQ63
2026-03-29 15:16:30 +02:00
committed by Janis
parent 280154e4d8
commit cce84cf781
@@ -0,0 +1,466 @@
# 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"
```