Files
NowChessSystems/docs/superpowers/plans/2026-03-29-en-passant.md
T
2026-03-29 15:16:30 +02:00

467 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"
```