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

20 KiB
Raw Blame History

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:

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
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:

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
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.EnPassantCalculatorTest"

Expected: all 14 tests GREEN.

  • Step 5: Commit
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):

  // ──── 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
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest"

Expected: the 4 new tests FAIL (ep target is never included by current legalTargets).

  • Step 3: Update MoveValidator.legalTargets(board, history, from)

In modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala, replace lines 154162:

  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:

  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
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest"

Expected: all tests GREEN including the 4 new ones.

  • Step 5: Commit
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:

  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
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest"

Expected: the 2 new tests FAIL — captured pawn is not removed (board still contains the double-pushed pawn at d5/e4).

  • Step 3: Update GameController.processMove

In modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala, replace lines 5156:

                  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:

                  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
./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
./gradlew :modules:core:test

Expected: all tests GREEN.

  • Step 6: Commit
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

cd C:/AIN-Festplatte/Softwarearchitekturen/NowChessSystems
./gradlew :modules:core:scoverageTest
  • Step 2: Check coverage gaps
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:

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"