From 280154e4d8deaf9377d4bd2fe8743f78f97aec3f Mon Sep 17 00:00:00 2001 From: LQ63 Date: Sun, 29 Mar 2026 14:48:09 +0200 Subject: [PATCH 1/7] docs: add en passant design spec for NCS-9 Co-Authored-By: Claude Sonnet 4.6 --- .../specs/2026-03-29-en-passant-design.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-29-en-passant-design.md diff --git a/docs/superpowers/specs/2026-03-29-en-passant-design.md b/docs/superpowers/specs/2026-03-29-en-passant-design.md new file mode 100644 index 0000000..456d5d7 --- /dev/null +++ b/docs/superpowers/specs/2026-03-29-en-passant-design.md @@ -0,0 +1,134 @@ +# 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 e2→e4 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`. -- 2.52.0 From cce84cf7813f001d69226bfbbde6fc1612f5fcc5 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Sun, 29 Mar 2026 15:16:30 +0200 Subject: [PATCH 2/7] docs: add en passant implementation plan for NCS-9 Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-03-29-en-passant.md | 466 ++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-29-en-passant.md 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" +``` -- 2.52.0 From f63b44994734fd59d44c1de0f8136c2c264682e5 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Sun, 29 Mar 2026 15:26:44 +0200 Subject: [PATCH 3/7] feat: NCS-9 add EnPassantCalculator with target derivation and capture logic Co-Authored-By: Claude Sonnet 4.6 --- .../chess/logic/EnPassantCalculator.scala | 32 ++++++ .../chess/logic/EnPassantCalculatorTest.scala | 101 ++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 modules/core/src/main/scala/de/nowchess/chess/logic/EnPassantCalculator.scala create mode 100644 modules/core/src/test/scala/de/nowchess/chess/logic/EnPassantCalculatorTest.scala diff --git a/modules/core/src/main/scala/de/nowchess/chess/logic/EnPassantCalculator.scala b/modules/core/src/main/scala/de/nowchess/chess/logic/EnPassantCalculator.scala new file mode 100644 index 0000000..88e6212 --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/logic/EnPassantCalculator.scala @@ -0,0 +1,32 @@ +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)) diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/EnPassantCalculatorTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/EnPassantCalculatorTest.scala new file mode 100644 index 0000000..31963f5 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/logic/EnPassantCalculatorTest.scala @@ -0,0 +1,101 @@ +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 -- 2.52.0 From 008881741e88ce383d3bfe68598307440ecfa0c3 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Sun, 29 Mar 2026 15:32:58 +0200 Subject: [PATCH 4/7] feat: NCS-9 include en passant square in pawn legal targets --- .../nowchess/chess/logic/MoveValidator.scala | 13 +++++++ .../chess/logic/MoveValidatorTest.scala | 37 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala b/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala index e40859e..22a8eee 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala @@ -155,8 +155,21 @@ object MoveValidator: 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 + def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean = legalTargets(board, history, from).contains(to) diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala index f47fec2..be1829a 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala @@ -211,3 +211,40 @@ class MoveValidatorTest extends AnyFunSuite with Matchers: sq(File.E, Rank.R4) -> Piece.BlackRook ) MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R4)) + + // ──── 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) -- 2.52.0 From 9a308945a221e93bed4be80414ad1670bda5a8bb Mon Sep 17 00:00:00 2001 From: LQ63 Date: Sun, 29 Mar 2026 15:37:47 +0200 Subject: [PATCH 5/7] test: NCS-9 cover case _ branch in legalTargets with history --- .../scala/de/nowchess/chess/logic/MoveValidatorTest.scala | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala index be1829a..6c819dd 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala @@ -248,3 +248,10 @@ class MoveValidatorTest extends AnyFunSuite with Matchers: ) 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) + + // ──── History-aware legalTargets fallback for non-pawn non-king pieces ───── + + test("legalTargets with history delegates to geometry-only for non-pawn non-king pieces"): + val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook) + val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) shouldBe MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) -- 2.52.0 From 61203a9ec4d497f10f54286dcc7350b40010b799 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Sun, 29 Mar 2026 15:40:39 +0200 Subject: [PATCH 6/7] feat: NCS-9 apply en passant board mutation in GameController Implement explicit removal of the captured pawn in en passant captures. The pawn is located at a different square than the destination (same file but one rank closer to the player). Use EnPassantCalculator to compute the capture square and remove it from the board after withMove. Co-Authored-By: Claude Sonnet 4.6 --- .../chess/controller/GameController.scala | 8 ++++- .../chess/controller/GameControllerTest.scala | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala index acb7d17..4717430 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala @@ -51,9 +51,15 @@ object GameController: 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 => board.withMove(from, to) + 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) val newHistory = history.addMove(from, to, castleOpt) GameRules.gameStatus(newBoard, newHistory, turn.opposite) match case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite) diff --git a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala index 5e1a71e..8124005 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala @@ -365,3 +365,39 @@ class GameControllerTest extends AnyFunSuite with Matchers: case MoveResult.MovedInCheck(_, newHistory, _, _) => castlingRights(newHistory, Color.White).queenSide shouldBe false case other => fail(s"Expected Moved or MovedInCheck, got $other") + + // ──── en passant ──────────────────────────────────────────────────────── + + 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") -- 2.52.0 From 9ab1317ece50b60d15d136a43772d34c374e1eb7 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Sun, 29 Mar 2026 16:06:54 +0200 Subject: [PATCH 7/7] revert(docs): Removed created docs from claude for en passant Removed created docs from claude for en passant and added them in the correct repo --- .../plans/2026-03-29-en-passant.md | 466 ------------------ .../specs/2026-03-29-en-passant-design.md | 134 ----- 2 files changed, 600 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-29-en-passant.md delete mode 100644 docs/superpowers/specs/2026-03-29-en-passant-design.md diff --git a/docs/superpowers/plans/2026-03-29-en-passant.md b/docs/superpowers/plans/2026-03-29-en-passant.md deleted file mode 100644 index d8d988d..0000000 --- a/docs/superpowers/plans/2026-03-29-en-passant.md +++ /dev/null @@ -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. 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" -``` diff --git a/docs/superpowers/specs/2026-03-29-en-passant-design.md b/docs/superpowers/specs/2026-03-29-en-passant-design.md deleted file mode 100644 index 456d5d7..0000000 --- a/docs/superpowers/specs/2026-03-29-en-passant-design.md +++ /dev/null @@ -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 e2→e4 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`. -- 2.52.0