# 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. e2→e4 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 154–162: ```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 51–56: ```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" ```