diff --git a/docs/superpowers/plans/2026-03-29-en-passant.md b/docs/superpowers/plans/2026-03-29-en-passant.md new file mode 100644 index 0000000..d8d988d --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-en-passant.md @@ -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. 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" +```