From 9ab1317ece50b60d15d136a43772d34c374e1eb7 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Sun, 29 Mar 2026 16:06:54 +0200 Subject: [PATCH] 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`.