revert(docs): Removed created docs from claude for en passant
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
Removed created docs from claude for en passant and added them in the correct repo
This commit is contained in:
@@ -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"
|
||||
```
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user