1367 lines
51 KiB
Markdown
1367 lines
51 KiB
Markdown
# Pawn Promotion 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 pawn promotion as a two-step interaction: detect pawn reaching back rank, prompt player to choose a promotion piece, apply the choice through GameEngine/TerminalUI, and record it in PGN export/import.
|
|
|
|
**Architecture:** Pure `GameController.processMove()` returns `PromotionRequired` when a pawn reaches the back rank. `GameEngine` stores pending promotion state and exposes `completePromotion(piece)`. `TerminalUI` listens for `PromotionRequiredEvent`, prompts the user, then calls `engine.completePromotion()` on the next input. PGN export/import preserves `=Q/=R/=B/=N` notation.
|
|
|
|
**Tech Stack:** Scala 3, core module (no Quarkus), ui module, scoverage for coverage, FEN for test board setup.
|
|
|
|
---
|
|
|
|
## Architecture Map
|
|
|
|
| Layer | File | Change |
|
|
|-------|------|--------|
|
|
| Logic | `MoveValidator.scala` | Add `isPromotionMove()` |
|
|
| Controller | `GameController.scala` | Add `PromotionRequired` to `MoveResult`; update `processMove()`; add `completePromotion()` |
|
|
| Observer | `Observer.scala` | Add `PromotionRequiredEvent` |
|
|
| Engine | `GameEngine.scala` | Add optional init params, pending promotion state, `isPendingPromotion`, `completePromotion()`, handle `PromotionRequired` in `processUserInput()` |
|
|
| UI | `TerminalUI.scala` (modules/ui) | Handle `PromotionRequiredEvent`, route promotion input to `engine.completePromotion()` |
|
|
| PGN Export | `PgnExporter.scala` | Emit `=Q/=R/=B/=N` suffix in `moveToAlgebraic()` |
|
|
| PGN Import | `PgnParser.scala` | Preserve promotion piece in `HistoryMove`; apply promoted piece to board state |
|
|
|
|
> **Task 1 is already complete:** `HistoryMove.promotionPiece: Option[PromotionPiece]` was added in commit `0800c3a` and tests already exist in `GameHistoryTest.scala`. Start from Task 2.
|
|
|
|
---
|
|
|
|
### Task 2: Add isPromotionMove() to MoveValidator
|
|
|
|
**Files:**
|
|
- Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala`
|
|
- Modify: `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Open `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` and add at the end of the class:
|
|
|
|
```scala
|
|
test("White pawn reaching R8 is a promotion move") {
|
|
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
MoveValidator.isPromotionMove(board, Square(File.E, Rank.R7), Square(File.E, Rank.R8)) should be (true)
|
|
}
|
|
|
|
test("Black pawn reaching R1 is a promotion move") {
|
|
val fen = "8/8/8/8/4K3/8/4p3/8 b - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
MoveValidator.isPromotionMove(board, Square(File.E, Rank.R2), Square(File.E, Rank.R1)) should be (true)
|
|
}
|
|
|
|
test("Pawn capturing to back rank is a promotion move") {
|
|
val fen = "3q4/4P3/8/8/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
MoveValidator.isPromotionMove(board, Square(File.E, Rank.R7), Square(File.D, Rank.R8)) should be (true)
|
|
}
|
|
|
|
test("Pawn not reaching back rank is not a promotion move") {
|
|
val fen = "8/8/8/4P3/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
MoveValidator.isPromotionMove(board, Square(File.E, Rank.R5), Square(File.E, Rank.R6)) should be (false)
|
|
}
|
|
|
|
test("Non-pawn piece is never a promotion move") {
|
|
val fen = "8/8/8/4Q3/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
MoveValidator.isPromotionMove(board, Square(File.E, Rank.R5), Square(File.E, Rank.R8)) should be (false)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: FAIL — `isPromotionMove` not found.
|
|
|
|
- [ ] **Step 3: Implement isPromotionMove()**
|
|
|
|
In `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala`, add after the existing `isLegal` overload (around line 174):
|
|
|
|
```scala
|
|
/** Returns true if the piece on `from` is a pawn moving to its back rank (promotion). */
|
|
def isPromotionMove(board: Board, from: Square, to: Square): Boolean =
|
|
board.pieceAt(from) match
|
|
case Some(Piece(_, PieceType.Pawn)) =>
|
|
(from.rank == Rank.R7 && to.rank == Rank.R8) ||
|
|
(from.rank == Rank.R2 && to.rank == Rank.R1)
|
|
case _ => false
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: All MoveValidatorTest tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
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: add isPromotionMove detection to MoveValidator"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Add PromotionRequired to MoveResult and update processMove()
|
|
|
|
**Files:**
|
|
- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`
|
|
- Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Open `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` and add:
|
|
|
|
```scala
|
|
test("processMove detects white pawn reaching R8 and returns PromotionRequired") {
|
|
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7e8")
|
|
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
|
result match
|
|
case MoveResult.PromotionRequired(from, to, _, _, _, turn) =>
|
|
from should be (Square(File.E, Rank.R7))
|
|
to should be (Square(File.E, Rank.R8))
|
|
turn should be (Color.White)
|
|
case _ => fail("Expected PromotionRequired")
|
|
}
|
|
|
|
test("processMove detects black pawn reaching R1 and returns PromotionRequired") {
|
|
val fen = "8/8/8/8/4K3/8/4p3/8 b - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
val result = GameController.processMove(board, GameHistory.empty, Color.Black, "e2e1")
|
|
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
|
}
|
|
|
|
test("processMove detects pawn capturing to back rank as PromotionRequired with captured piece") {
|
|
val fen = "3q4/4P3/8/8/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7d8")
|
|
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
|
result match
|
|
case MoveResult.PromotionRequired(_, _, _, _, captured, _) =>
|
|
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
|
|
case _ => fail("Expected PromotionRequired")
|
|
}
|
|
```
|
|
|
|
You will need to add these imports to `GameControllerTest.scala` if not already present:
|
|
|
|
```scala
|
|
import de.nowchess.chess.notation.FenParser
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: FAIL — `PromotionRequired` not a member of `MoveResult`.
|
|
|
|
- [ ] **Step 3: Add PromotionRequired to MoveResult ADT**
|
|
|
|
In `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`, add `PromotionRequired` to the `MoveResult` object **after** `IllegalMove` and **before** `Moved`. Also add the `PromotionPiece` import at the top of the file:
|
|
|
|
Add import after the existing imports:
|
|
```scala
|
|
import de.nowchess.api.move.PromotionPiece
|
|
```
|
|
|
|
Add to `MoveResult` object:
|
|
```scala
|
|
case class PromotionRequired(
|
|
from: Square,
|
|
to: Square,
|
|
boardBefore: Board,
|
|
historyBefore: GameHistory,
|
|
captured: Option[Piece],
|
|
turn: Color
|
|
) extends MoveResult
|
|
```
|
|
|
|
The full updated `MoveResult` object should be:
|
|
```scala
|
|
sealed trait MoveResult
|
|
object MoveResult:
|
|
case object Quit extends MoveResult
|
|
case class InvalidFormat(raw: String) extends MoveResult
|
|
case object NoPiece extends MoveResult
|
|
case object WrongColor extends MoveResult
|
|
case object IllegalMove extends MoveResult
|
|
case class PromotionRequired(from: Square, to: Square, boardBefore: Board, historyBefore: GameHistory, captured: Option[Piece], turn: Color) extends MoveResult
|
|
case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
|
case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
|
case class Checkmate(winner: Color) extends MoveResult
|
|
case object Stalemate extends MoveResult
|
|
```
|
|
|
|
- [ ] **Step 4: Update processMove() to detect promotion before executing the move**
|
|
|
|
In `GameController.processMove()`, add the promotion check after the `isLegal` check and before the castle/en-passant logic. Replace the `else` clause from `if !MoveValidator.isLegal(...)` with:
|
|
|
|
```scala
|
|
if !MoveValidator.isLegal(board, history, from, to) then
|
|
MoveResult.IllegalMove
|
|
else if MoveValidator.isPromotionMove(board, from, to) then
|
|
val captured = board.pieceAt(to)
|
|
MoveResult.PromotionRequired(from, to, board, history, captured, turn)
|
|
else
|
|
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)
|
|
val newHistory = history.addMove(from, to, castleOpt)
|
|
GameRules.gameStatus(newBoard, newHistory, turn.opposite) match
|
|
case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite)
|
|
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite)
|
|
case PositionStatus.Mated => MoveResult.Checkmate(turn)
|
|
case PositionStatus.Drawn => MoveResult.Stalemate
|
|
```
|
|
|
|
- [ ] **Step 5: Run test to verify it passes**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: All GameControllerTest tests pass.
|
|
|
|
- [ ] **Step 6: Run full core tests to check no regressions**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test 2>&1 | tail -15
|
|
```
|
|
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
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: add PromotionRequired to MoveResult and detect promotion in processMove"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Add completePromotion() to GameController
|
|
|
|
**Files:**
|
|
- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`
|
|
- Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Add to `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala`:
|
|
|
|
```scala
|
|
test("completePromotion applies move and places queen") {
|
|
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
val result = GameController.completePromotion(
|
|
board, GameHistory.empty,
|
|
Square(File.E, Rank.R7), Square(File.E, Rank.R8),
|
|
PromotionPiece.Queen, Color.White
|
|
)
|
|
result should matchPattern { case _: MoveResult.Moved => }
|
|
result match
|
|
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
newBoard.pieceAt(Square(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
|
newBoard.pieceAt(Square(File.E, Rank.R7)) should be (None)
|
|
newHistory.moves should have length 1
|
|
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
|
case _ => fail("Expected Moved")
|
|
}
|
|
|
|
test("completePromotion with rook underpromotion") {
|
|
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
val result = GameController.completePromotion(
|
|
board, GameHistory.empty,
|
|
Square(File.E, Rank.R7), Square(File.E, Rank.R8),
|
|
PromotionPiece.Rook, Color.White
|
|
)
|
|
result match
|
|
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
newBoard.pieceAt(Square(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook)))
|
|
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook))
|
|
case _ => fail("Expected Moved with Rook")
|
|
}
|
|
|
|
test("completePromotion with bishop underpromotion") {
|
|
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
val result = GameController.completePromotion(
|
|
board, GameHistory.empty,
|
|
Square(File.E, Rank.R7), Square(File.E, Rank.R8),
|
|
PromotionPiece.Bishop, Color.White
|
|
)
|
|
result match
|
|
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
newBoard.pieceAt(Square(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Bishop)))
|
|
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Bishop))
|
|
case _ => fail("Expected Moved with Bishop")
|
|
}
|
|
|
|
test("completePromotion with knight underpromotion") {
|
|
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
val result = GameController.completePromotion(
|
|
board, GameHistory.empty,
|
|
Square(File.E, Rank.R7), Square(File.E, Rank.R8),
|
|
PromotionPiece.Knight, Color.White
|
|
)
|
|
result match
|
|
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
newBoard.pieceAt(Square(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Knight)))
|
|
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight))
|
|
case _ => fail("Expected Moved with Knight")
|
|
}
|
|
|
|
test("completePromotion captures opponent piece") {
|
|
val fen = "3q4/4P3/8/8/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
val result = GameController.completePromotion(
|
|
board, GameHistory.empty,
|
|
Square(File.E, Rank.R7), Square(File.D, Rank.R8),
|
|
PromotionPiece.Queen, Color.White
|
|
)
|
|
result match
|
|
case MoveResult.Moved(newBoard, _, captured, _) =>
|
|
newBoard.pieceAt(Square(File.D, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
|
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
|
|
case _ => fail("Expected Moved with captured piece")
|
|
}
|
|
|
|
test("completePromotion for black pawn to R1") {
|
|
val fen = "8/8/8/8/4K3/8/4p3/8 b - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
val result = GameController.completePromotion(
|
|
board, GameHistory.empty,
|
|
Square(File.E, Rank.R2), Square(File.E, Rank.R1),
|
|
PromotionPiece.Knight, Color.Black
|
|
)
|
|
result match
|
|
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
|
newBoard.pieceAt(Square(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Knight)))
|
|
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight))
|
|
case _ => fail("Expected Moved")
|
|
}
|
|
|
|
test("completePromotion evaluates check after promotion") {
|
|
val fen = "3k4/4P3/8/8/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
val result = GameController.completePromotion(
|
|
board, GameHistory.empty,
|
|
Square(File.E, Rank.R7), Square(File.E, Rank.R8),
|
|
PromotionPiece.Queen, Color.White
|
|
)
|
|
result should matchPattern { case _: MoveResult.MovedInCheck => }
|
|
}
|
|
|
|
test("completePromotion full round-trip via processMove then completePromotion") {
|
|
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
GameController.processMove(board, GameHistory.empty, Color.White, "e7e8") match
|
|
case MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, turn) =>
|
|
val result = GameController.completePromotion(boardBefore, histBefore, from, to, PromotionPiece.Queen, turn)
|
|
result should matchPattern { case _: MoveResult.Moved => }
|
|
result match
|
|
case MoveResult.Moved(finalBoard, finalHistory, _, _) =>
|
|
finalBoard.pieceAt(Square(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
|
finalHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
|
case _ => fail("Expected Moved")
|
|
case _ => fail("Expected PromotionRequired")
|
|
}
|
|
```
|
|
|
|
You will need to add this import to `GameControllerTest.scala`:
|
|
|
|
```scala
|
|
import de.nowchess.api.move.PromotionPiece
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: FAIL — `completePromotion` not found.
|
|
|
|
- [ ] **Step 3: Implement completePromotion()**
|
|
|
|
Add to `GameController` object in `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`:
|
|
|
|
```scala
|
|
/** Apply a previously detected promotion move with the chosen piece.
|
|
* Called after processMove returned PromotionRequired.
|
|
*/
|
|
def completePromotion(
|
|
board: Board,
|
|
history: GameHistory,
|
|
from: Square,
|
|
to: Square,
|
|
piece: PromotionPiece,
|
|
turn: Color
|
|
): MoveResult =
|
|
val (boardAfterMove, captured) = board.withMove(from, to)
|
|
val promotedPieceType = piece match
|
|
case PromotionPiece.Queen => PieceType.Queen
|
|
case PromotionPiece.Rook => PieceType.Rook
|
|
case PromotionPiece.Bishop => PieceType.Bishop
|
|
case PromotionPiece.Knight => PieceType.Knight
|
|
val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType))
|
|
val newHistory = history.addMove(from, to, None, Some(piece))
|
|
GameRules.gameStatus(newBoard, newHistory, turn.opposite) match
|
|
case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite)
|
|
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite)
|
|
case PositionStatus.Mated => MoveResult.Checkmate(turn)
|
|
case PositionStatus.Drawn => MoveResult.Stalemate
|
|
```
|
|
|
|
Also add `PieceType` and `Piece` to the imports if not already there. The file already imports `de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}` so `Piece` is covered; add `PieceType`:
|
|
|
|
```scala
|
|
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: All GameControllerTest tests pass.
|
|
|
|
- [ ] **Step 5: Run full core tests**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
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: add completePromotion to GameController to finalize promotion moves"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Add PromotionRequiredEvent to Observer
|
|
|
|
**Files:**
|
|
- Modify: `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala`
|
|
|
|
- [ ] **Step 1: Add PromotionRequiredEvent case class**
|
|
|
|
In `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala`, add after `InvalidMoveEvent` and before `BoardResetEvent`. Also add the import for `Square` (it's already in `de.nowchess.api.board.*` but verify the import covers it):
|
|
|
|
```scala
|
|
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
|
|
case class PromotionRequiredEvent(
|
|
board: Board,
|
|
history: GameHistory,
|
|
turn: Color,
|
|
from: Square,
|
|
to: Square
|
|
) extends GameEvent
|
|
```
|
|
|
|
The existing import `import de.nowchess.api.board.{Board, Color}` must be expanded to include `Square`:
|
|
|
|
```scala
|
|
import de.nowchess.api.board.{Board, Color, Square}
|
|
```
|
|
|
|
- [ ] **Step 2: Run full core test suite to ensure nothing broke**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: All tests pass (adding a new case class with no logic can't break anything).
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala
|
|
git commit -m "feat: add PromotionRequiredEvent to Observer for pawn promotion notification"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Update GameEngine to handle promotion
|
|
|
|
**Files:**
|
|
- Modify: `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`
|
|
- Create: `modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Create `modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala`:
|
|
|
|
```scala
|
|
package de.nowchess.chess.engine
|
|
|
|
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
|
import de.nowchess.api.move.PromotionPiece
|
|
import de.nowchess.chess.logic.GameHistory
|
|
import de.nowchess.chess.notation.FenParser
|
|
import de.nowchess.chess.observer.*
|
|
import org.scalatest.funsuite.AnyFunSuite
|
|
import org.scalatest.matchers.should.Matchers
|
|
|
|
class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
|
|
|
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
|
|
private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] =
|
|
val events = collection.mutable.ListBuffer[GameEvent]()
|
|
engine.subscribe(new Observer { def onGameEvent(e: GameEvent): Unit = events += e })
|
|
events
|
|
|
|
test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") {
|
|
val promotionBoard = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board
|
|
val engine = new GameEngine(initialBoard = promotionBoard)
|
|
val events = captureEvents(engine)
|
|
|
|
engine.processUserInput("e7e8")
|
|
|
|
events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be (true)
|
|
events.collect { case e: PromotionRequiredEvent => e }.head.from should be (sq(File.E, Rank.R7))
|
|
}
|
|
|
|
test("isPendingPromotion is true after PromotionRequired input") {
|
|
val promotionBoard = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board
|
|
val engine = new GameEngine(initialBoard = promotionBoard)
|
|
captureEvents(engine)
|
|
|
|
engine.processUserInput("e7e8")
|
|
|
|
engine.isPendingPromotion should be (true)
|
|
}
|
|
|
|
test("isPendingPromotion is false before any promotion input") {
|
|
val engine = new GameEngine()
|
|
engine.isPendingPromotion should be (false)
|
|
}
|
|
|
|
test("completePromotion fires MoveExecutedEvent with promoted piece") {
|
|
val promotionBoard = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board
|
|
val engine = new GameEngine(initialBoard = promotionBoard)
|
|
val events = captureEvents(engine)
|
|
|
|
engine.processUserInput("e7e8")
|
|
engine.completePromotion(PromotionPiece.Queen)
|
|
|
|
engine.isPendingPromotion should be (false)
|
|
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
|
engine.board.pieceAt(sq(File.E, Rank.R7)) should be (None)
|
|
engine.history.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
|
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
|
}
|
|
|
|
test("completePromotion with rook underpromotion") {
|
|
val promotionBoard = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board
|
|
val engine = new GameEngine(initialBoard = promotionBoard)
|
|
captureEvents(engine)
|
|
|
|
engine.processUserInput("e7e8")
|
|
engine.completePromotion(PromotionPiece.Rook)
|
|
|
|
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook)))
|
|
}
|
|
|
|
test("completePromotion with no pending promotion fires InvalidMoveEvent") {
|
|
val engine = new GameEngine()
|
|
val events = captureEvents(engine)
|
|
|
|
engine.completePromotion(PromotionPiece.Queen)
|
|
|
|
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
|
|
engine.isPendingPromotion should be (false)
|
|
}
|
|
|
|
test("completePromotion fires CheckDetectedEvent when promotion gives check") {
|
|
val promotionBoard = FenParser.parse("3k4/4P3/8/8/8/8/8/8 w - - 0 1").board
|
|
val engine = new GameEngine(initialBoard = promotionBoard)
|
|
val events = captureEvents(engine)
|
|
|
|
engine.processUserInput("e7e8")
|
|
engine.completePromotion(PromotionPiece.Queen)
|
|
|
|
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (true)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.engine.GameEnginePromotionTest" 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: FAIL — `GameEngine` has no `initialBoard` param, `isPendingPromotion`, or `completePromotion`.
|
|
|
|
- [ ] **Step 3: Update GameEngine class signature and add promotion state**
|
|
|
|
In `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`:
|
|
|
|
1. Change the class definition to accept optional initial state:
|
|
|
|
```scala
|
|
class GameEngine(
|
|
initialBoard: Board = Board.initial,
|
|
initialHistory: GameHistory = GameHistory.empty,
|
|
initialTurn: Color = Color.White
|
|
) extends Observable:
|
|
private var currentBoard: Board = initialBoard
|
|
private var currentHistory: GameHistory = initialHistory
|
|
private var currentTurn: Color = initialTurn
|
|
private val invoker = new CommandInvoker()
|
|
```
|
|
|
|
2. Add `PromotionPiece` import at the top (with existing imports):
|
|
|
|
```scala
|
|
import de.nowchess.api.move.PromotionPiece
|
|
```
|
|
|
|
3. Add after the `commandHistory` def:
|
|
|
|
```scala
|
|
/** True if a pawn promotion move is pending and needs a piece choice. */
|
|
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
|
|
```
|
|
|
|
4. Add private state and inner class before `// Synchronized accessors`:
|
|
|
|
```scala
|
|
private case class PendingPromotion(
|
|
from: Square, to: Square,
|
|
boardBefore: Board, historyBefore: GameHistory,
|
|
turn: Color
|
|
)
|
|
private var pendingPromotion: Option[PendingPromotion] = None
|
|
```
|
|
|
|
- [ ] **Step 4: Handle PromotionRequired in processUserInput()**
|
|
|
|
In `processUserInput()`, add a case for `PromotionRequired` in the `GameController.processMove(...)` match. After the `MoveResult.Stalemate` case and before the closing brace, add:
|
|
|
|
```scala
|
|
case MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, promotingTurn) =>
|
|
pendingPromotion = Some(PendingPromotion(from, to, boardBefore, histBefore, promotingTurn))
|
|
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, from, to))
|
|
```
|
|
|
|
Also update the `case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>` line to include `MoveResult.PromotionRequired` in the failure list is NOT needed — `PromotionRequired` is a success path. However the match may become non-exhaustive. Ensure the `handleFailedMove` branch still only covers the failure cases (it's `@unchecked` so it won't warn, but confirm the match above is exhaustive).
|
|
|
|
The updated match in `processUserInput` should be:
|
|
|
|
```scala
|
|
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
|
|
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
|
|
handleFailedMove(moveInput)
|
|
|
|
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
|
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
|
invoker.execute(updatedCmd)
|
|
updateGameState(newBoard, newHistory, newTurn)
|
|
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
|
|
|
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
|
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
|
invoker.execute(updatedCmd)
|
|
updateGameState(newBoard, newHistory, newTurn)
|
|
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
|
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
|
|
|
case MoveResult.Checkmate(winner) =>
|
|
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
|
invoker.execute(updatedCmd)
|
|
currentBoard = Board.initial
|
|
currentHistory = GameHistory.empty
|
|
currentTurn = Color.White
|
|
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
|
|
|
|
case MoveResult.Stalemate =>
|
|
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
|
invoker.execute(updatedCmd)
|
|
currentBoard = Board.initial
|
|
currentHistory = GameHistory.empty
|
|
currentTurn = Color.White
|
|
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
|
|
|
case MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, promotingTurn) =>
|
|
pendingPromotion = Some(PendingPromotion(from, to, boardBefore, histBefore, promotingTurn))
|
|
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, from, to))
|
|
```
|
|
|
|
- [ ] **Step 5: Add completePromotion() method**
|
|
|
|
Add after the `redo()` method in `GameEngine.scala`:
|
|
|
|
```scala
|
|
/** Apply a player's promotion piece choice.
|
|
* Must only be called when isPendingPromotion is true.
|
|
*/
|
|
def completePromotion(piece: PromotionPiece): Unit = synchronized {
|
|
pendingPromotion match
|
|
case None =>
|
|
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "No promotion pending."))
|
|
case Some(pending) =>
|
|
pendingPromotion = None
|
|
val cmd = MoveCommand(
|
|
from = pending.from,
|
|
to = pending.to,
|
|
previousBoard = Some(pending.boardBefore),
|
|
previousHistory = Some(pending.historyBefore),
|
|
previousTurn = Some(pending.turn)
|
|
)
|
|
GameController.completePromotion(
|
|
pending.boardBefore, pending.historyBefore,
|
|
pending.from, pending.to, piece, pending.turn
|
|
) match
|
|
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
|
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
|
invoker.execute(updatedCmd)
|
|
updateGameState(newBoard, newHistory, newTurn)
|
|
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
|
|
|
|
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
|
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
|
invoker.execute(updatedCmd)
|
|
updateGameState(newBoard, newHistory, newTurn)
|
|
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
|
|
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
|
|
|
case MoveResult.Checkmate(winner) =>
|
|
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
|
invoker.execute(updatedCmd)
|
|
currentBoard = Board.initial
|
|
currentHistory = GameHistory.empty
|
|
currentTurn = Color.White
|
|
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
|
|
|
|
case MoveResult.Stalemate =>
|
|
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
|
invoker.execute(updatedCmd)
|
|
currentBoard = Board.initial
|
|
currentHistory = GameHistory.empty
|
|
currentTurn = Color.White
|
|
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
|
|
|
case _ =>
|
|
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Error completing promotion."))
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Run test to verify it passes**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.engine.GameEnginePromotionTest" 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: All GameEnginePromotionTest tests pass.
|
|
|
|
- [ ] **Step 7: Run full core tests**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala \
|
|
modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala
|
|
git commit -m "feat: add promotion handling to GameEngine with pending state and completePromotion()"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Update TerminalUI to handle promotion I/O
|
|
|
|
**Files:**
|
|
- Modify: `modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala`
|
|
- Modify: `modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Add to `modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala`:
|
|
|
|
```scala
|
|
test("TerminalUI shows promotion prompt on PromotionRequiredEvent") {
|
|
val out = new ByteArrayOutputStream()
|
|
val engine = new GameEngine()
|
|
val ui = new TerminalUI(engine)
|
|
|
|
Console.withOut(out) {
|
|
ui.onGameEvent(PromotionRequiredEvent(
|
|
Board(Map.empty), GameHistory(), Color.White,
|
|
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
|
|
))
|
|
}
|
|
|
|
out.toString should include("Promote to")
|
|
}
|
|
|
|
test("TerminalUI routes promotion choice to engine.completePromotion") {
|
|
import de.nowchess.api.move.PromotionPiece
|
|
import de.nowchess.api.board.{File, Rank, Square}
|
|
|
|
var capturedPiece: Option[PromotionPiece] = None
|
|
|
|
val engine = new GameEngine() {
|
|
override def processUserInput(rawInput: String): Unit =
|
|
if rawInput.trim == "e7e8" then
|
|
notifyObservers(PromotionRequiredEvent(
|
|
Board(Map.empty), GameHistory.empty, Color.White,
|
|
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
|
|
))
|
|
override def completePromotion(piece: PromotionPiece): Unit =
|
|
capturedPiece = Some(piece)
|
|
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
|
|
}
|
|
|
|
val in = new ByteArrayInputStream("e7e8\nq\nquit\n".getBytes)
|
|
val out = new ByteArrayOutputStream()
|
|
val ui = new TerminalUI(engine)
|
|
|
|
Console.withIn(in) {
|
|
Console.withOut(out) {
|
|
ui.start()
|
|
}
|
|
}
|
|
|
|
capturedPiece should be (Some(PromotionPiece.Queen))
|
|
out.toString should include("Promote to")
|
|
}
|
|
|
|
test("TerminalUI re-prompts on invalid promotion choice") {
|
|
import de.nowchess.api.move.PromotionPiece
|
|
import de.nowchess.api.board.{File, Rank, Square}
|
|
|
|
var capturedPiece: Option[PromotionPiece] = None
|
|
|
|
val engine = new GameEngine() {
|
|
override def processUserInput(rawInput: String): Unit =
|
|
if rawInput.trim == "e7e8" then
|
|
notifyObservers(PromotionRequiredEvent(
|
|
Board(Map.empty), GameHistory.empty, Color.White,
|
|
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
|
|
))
|
|
override def completePromotion(piece: PromotionPiece): Unit =
|
|
capturedPiece = Some(piece)
|
|
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
|
|
}
|
|
|
|
// "x" is invalid, then "r" for rook
|
|
val in = new ByteArrayInputStream("e7e8\nx\nr\nquit\n".getBytes)
|
|
val out = new ByteArrayOutputStream()
|
|
val ui = new TerminalUI(engine)
|
|
|
|
Console.withIn(in) {
|
|
Console.withOut(out) {
|
|
ui.start()
|
|
}
|
|
}
|
|
|
|
capturedPiece should be (Some(PromotionPiece.Rook))
|
|
out.toString should include("Invalid")
|
|
}
|
|
```
|
|
|
|
You will need to add to the imports at the top of `TerminalUITest.scala`:
|
|
|
|
```scala
|
|
import de.nowchess.chess.observer.PromotionRequiredEvent
|
|
import de.nowchess.api.board.{File, Rank, Square}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
./gradlew :modules:ui:test --tests "de.nowchess.ui.terminal.TerminalUITest" 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: FAIL — `PromotionRequiredEvent` not handled, no promotion routing in game loop.
|
|
|
|
- [ ] **Step 3: Update TerminalUI**
|
|
|
|
Replace the entire content of `modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala` with:
|
|
|
|
```scala
|
|
package de.nowchess.ui.terminal
|
|
|
|
import scala.io.StdIn
|
|
import de.nowchess.api.move.PromotionPiece
|
|
import de.nowchess.chess.engine.GameEngine
|
|
import de.nowchess.chess.observer.{Observer, GameEvent, *}
|
|
import de.nowchess.chess.view.Renderer
|
|
|
|
/** Terminal UI that implements Observer pattern.
|
|
* Subscribes to GameEngine and receives state change events.
|
|
* Handles all I/O and user interaction in the terminal.
|
|
*/
|
|
class TerminalUI(engine: GameEngine) extends Observer:
|
|
private var running = true
|
|
private var awaitingPromotion = false
|
|
|
|
/** Called by GameEngine whenever a game event occurs. */
|
|
override def onGameEvent(event: GameEvent): Unit =
|
|
event match
|
|
case e: MoveExecutedEvent =>
|
|
println()
|
|
print(Renderer.render(e.board))
|
|
e.capturedPiece.foreach: cap =>
|
|
println(s"Captured: $cap on ${e.toSquare}")
|
|
printPrompt(e.turn)
|
|
|
|
case e: CheckDetectedEvent =>
|
|
println(s"${e.turn.label} is in check!")
|
|
|
|
case e: CheckmateEvent =>
|
|
println(s"Checkmate! ${e.winner.label} wins.")
|
|
println()
|
|
print(Renderer.render(e.board))
|
|
|
|
case e: StalemateEvent =>
|
|
println("Stalemate! The game is a draw.")
|
|
println()
|
|
print(Renderer.render(e.board))
|
|
|
|
case e: InvalidMoveEvent =>
|
|
println(s"⚠️ ${e.reason}")
|
|
|
|
case e: BoardResetEvent =>
|
|
println("Board has been reset to initial position.")
|
|
println()
|
|
print(Renderer.render(e.board))
|
|
printPrompt(e.turn)
|
|
|
|
case _: PromotionRequiredEvent =>
|
|
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
|
|
awaitingPromotion = true
|
|
|
|
/** Start the terminal UI game loop. */
|
|
def start(): Unit =
|
|
// Register as observer
|
|
engine.subscribe(this)
|
|
|
|
// Show initial board
|
|
println()
|
|
print(Renderer.render(engine.board))
|
|
printPrompt(engine.turn)
|
|
|
|
// Game loop
|
|
while running do
|
|
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
|
if awaitingPromotion then
|
|
input.toLowerCase match
|
|
case "q" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Queen)
|
|
case "r" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Rook)
|
|
case "b" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Bishop)
|
|
case "n" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Knight)
|
|
case _ =>
|
|
println("Invalid choice. Enter q, r, b, or n.")
|
|
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
|
|
else
|
|
input.toLowerCase match
|
|
case "quit" | "q" =>
|
|
running = false
|
|
println("Game over. Goodbye!")
|
|
case "" =>
|
|
printPrompt(engine.turn)
|
|
case _ =>
|
|
engine.processUserInput(input)
|
|
|
|
// Unsubscribe when done
|
|
engine.unsubscribe(this)
|
|
|
|
private def printPrompt(turn: de.nowchess.api.board.Color): Unit =
|
|
val undoHint = if engine.canUndo then " [undo]" else ""
|
|
val redoHint = if engine.canRedo then " [redo]" else ""
|
|
print(s"${turn.label}'s turn. Enter move (or 'quit'/'q' to exit)$undoHint$redoHint: ")
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
```bash
|
|
./gradlew :modules:ui:test --tests "de.nowchess.ui.terminal.TerminalUITest" 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: All TerminalUITest tests pass.
|
|
|
|
- [ ] **Step 5: Run full build to check both modules**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test :modules:ui:test 2>&1 | tail -15
|
|
```
|
|
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala \
|
|
modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala
|
|
git commit -m "feat: update TerminalUI to handle pawn promotion I/O via awaitingPromotion flag"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: PGN export support for promotions
|
|
|
|
**Files:**
|
|
- Modify: `modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala`
|
|
- Modify: `modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Add to `modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala`:
|
|
|
|
```scala
|
|
test("exportGame encodes promotion to Queen as =Q suffix") {
|
|
val history = GameHistory()
|
|
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen)))
|
|
val pgn = PgnExporter.exportGame(Map.empty, history)
|
|
pgn should include ("e7e8=Q")
|
|
}
|
|
|
|
test("exportGame encodes promotion to Rook as =R suffix") {
|
|
val history = GameHistory()
|
|
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook)))
|
|
val pgn = PgnExporter.exportGame(Map.empty, history)
|
|
pgn should include ("e7e8=R")
|
|
}
|
|
|
|
test("exportGame encodes promotion to Bishop as =B suffix") {
|
|
val history = GameHistory()
|
|
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Bishop)))
|
|
val pgn = PgnExporter.exportGame(Map.empty, history)
|
|
pgn should include ("e7e8=B")
|
|
}
|
|
|
|
test("exportGame encodes promotion to Knight as =N suffix") {
|
|
val history = GameHistory()
|
|
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Knight)))
|
|
val pgn = PgnExporter.exportGame(Map.empty, history)
|
|
pgn should include ("e7e8=N")
|
|
}
|
|
|
|
test("exportGame does not add suffix for normal moves") {
|
|
val history = GameHistory()
|
|
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None))
|
|
val pgn = PgnExporter.exportGame(Map.empty, history)
|
|
pgn should include ("e2e4")
|
|
pgn should not include ("=")
|
|
}
|
|
```
|
|
|
|
Add the import at the top of `PgnExporterTest.scala`:
|
|
|
|
```scala
|
|
import de.nowchess.api.move.PromotionPiece
|
|
import de.nowchess.api.board.{Rank}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnExporterTest" 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: FAIL — normal moves export fine, but promotion moves don't emit `=Q` etc.
|
|
|
|
- [ ] **Step 3: Update moveToAlgebraic() in PgnExporter**
|
|
|
|
In `modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala`:
|
|
|
|
1. Add import after existing imports:
|
|
|
|
```scala
|
|
import de.nowchess.api.move.PromotionPiece
|
|
```
|
|
|
|
2. Replace the existing `moveToAlgebraic` method:
|
|
|
|
```scala
|
|
private def moveToAlgebraic(move: HistoryMove): String =
|
|
move.castleSide match
|
|
case Some(CastleSide.Kingside) => "O-O"
|
|
case Some(CastleSide.Queenside) => "O-O-O"
|
|
case None =>
|
|
val base = s"${move.from}${move.to}"
|
|
move.promotionPiece match
|
|
case Some(PromotionPiece.Queen) => s"$base=Q"
|
|
case Some(PromotionPiece.Rook) => s"$base=R"
|
|
case Some(PromotionPiece.Bishop) => s"$base=B"
|
|
case Some(PromotionPiece.Knight) => s"$base=N"
|
|
case None => base
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnExporterTest" 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: All PgnExporterTest tests pass.
|
|
|
|
- [ ] **Step 5: Run full core tests**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala \
|
|
modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala
|
|
git commit -m "feat: add PGN export support for pawn promotion notation (=Q/=R/=B/=N)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: PGN import support for promotions
|
|
|
|
**Files:**
|
|
- Modify: `modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala`
|
|
- Modify: `modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Add to `modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala`:
|
|
|
|
```scala
|
|
test("parseAlgebraicMove preserves promotion to Queen in HistoryMove") {
|
|
val board = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board
|
|
val result = PgnParser.parseAlgebraicMove("e7e8=Q", board, GameHistory.empty, Color.White)
|
|
result.isDefined should be (true)
|
|
result.get.promotionPiece should be (Some(PromotionPiece.Queen))
|
|
result.get.to should be (Square(File.E, Rank.R8))
|
|
}
|
|
|
|
test("parseAlgebraicMove preserves promotion to Rook") {
|
|
val board = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board
|
|
val result = PgnParser.parseAlgebraicMove("e7e8=R", board, GameHistory.empty, Color.White)
|
|
result.get.promotionPiece should be (Some(PromotionPiece.Rook))
|
|
}
|
|
|
|
test("parseAlgebraicMove preserves promotion to Bishop") {
|
|
val board = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board
|
|
val result = PgnParser.parseAlgebraicMove("e7e8=B", board, GameHistory.empty, Color.White)
|
|
result.get.promotionPiece should be (Some(PromotionPiece.Bishop))
|
|
}
|
|
|
|
test("parseAlgebraicMove preserves promotion to Knight") {
|
|
val board = FenParser.parse("8/4P3/4k3/8/8/8/8/8 w - - 0 1").board
|
|
val result = PgnParser.parseAlgebraicMove("e7e8=N", board, GameHistory.empty, Color.White)
|
|
result.get.promotionPiece should be (Some(PromotionPiece.Knight))
|
|
}
|
|
|
|
test("parsePgn applies promoted piece to board for subsequent moves") {
|
|
// White promotes e7 to Queen, then Black King moves — should parse both
|
|
val pgn = """[Event "Test"]
|
|
[White "A"]
|
|
[Black "B"]
|
|
|
|
1. e7e8=Q Ke7
|
|
"""
|
|
val result = PgnParser.parsePgn(pgn)
|
|
result.isDefined should be (true)
|
|
result.get.moves.length should be (2)
|
|
result.get.moves(0).promotionPiece should be (Some(PromotionPiece.Queen))
|
|
}
|
|
```
|
|
|
|
Add imports at the top of `PgnParserTest.scala`:
|
|
|
|
```scala
|
|
import de.nowchess.api.move.PromotionPiece
|
|
import de.nowchess.chess.notation.FenParser
|
|
import de.nowchess.api.board.{File, Rank}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnParserTest" 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: FAIL — `promotionPiece` is always `None` since PgnParser strips `=[NBRQ]` but doesn't record it.
|
|
|
|
- [ ] **Step 3: Update PgnParser to preserve promotion piece**
|
|
|
|
In `modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala`:
|
|
|
|
1. Add imports at the top:
|
|
|
|
```scala
|
|
import de.nowchess.api.move.PromotionPiece
|
|
```
|
|
|
|
2. Add a private helper method to extract promotion piece from notation:
|
|
|
|
```scala
|
|
private def extractPromotion(notation: String): Option[PromotionPiece] =
|
|
val promotionPattern = """=([QRBN])""".r
|
|
promotionPattern.findFirstMatchIn(notation).flatMap { m =>
|
|
m.group(1) match
|
|
case "Q" => Some(PromotionPiece.Queen)
|
|
case "R" => Some(PromotionPiece.Rook)
|
|
case "B" => Some(PromotionPiece.Bishop)
|
|
case "N" => Some(PromotionPiece.Knight)
|
|
case _ => None
|
|
}
|
|
```
|
|
|
|
3. In `parseRegularMove`, change the last line from:
|
|
|
|
```scala
|
|
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None))
|
|
```
|
|
|
|
to:
|
|
|
|
```scala
|
|
val promotion = extractPromotion(notation)
|
|
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion))
|
|
```
|
|
|
|
4. In `parseMovesText`, update the board-state update after a move to apply the promoted piece. Replace:
|
|
|
|
```scala
|
|
val newBoard = move.castleSide match
|
|
case Some(side) => board.withCastle(color, side)
|
|
case None => board.withMove(move.from, move.to)._1
|
|
```
|
|
|
|
with:
|
|
|
|
```scala
|
|
val newBoard = move.castleSide match
|
|
case Some(side) => board.withCastle(color, side)
|
|
case None =>
|
|
val (boardAfterMove, _) = board.withMove(move.from, move.to)
|
|
move.promotionPiece match
|
|
case Some(pp) =>
|
|
val pieceType = pp match
|
|
case PromotionPiece.Queen => PieceType.Queen
|
|
case PromotionPiece.Rook => PieceType.Rook
|
|
case PromotionPiece.Bishop => PieceType.Bishop
|
|
case PromotionPiece.Knight => PieceType.Knight
|
|
boardAfterMove.updated(move.to, Piece(color, pieceType))
|
|
case None => boardAfterMove
|
|
```
|
|
|
|
This requires `Piece` and `PieceType` in scope; they come from the existing `import de.nowchess.api.board.*`.
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnParserTest" 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: All PgnParserTest tests pass.
|
|
|
|
- [ ] **Step 5: Run full core tests**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala \
|
|
modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala
|
|
git commit -m "feat: add PGN import support for pawn promotion notation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Final integration and coverage verification
|
|
|
|
**Files:** All modified files from previous tasks.
|
|
|
|
- [ ] **Step 1: Full build**
|
|
|
|
```bash
|
|
./gradlew build 2>&1 | tail -20
|
|
```
|
|
|
|
Expected: BUILD SUCCESSFUL across all modules.
|
|
|
|
- [ ] **Step 2: Check coverage gaps for core module**
|
|
|
|
```bash
|
|
./gradlew :modules:core:scoverageTest && \
|
|
python jacoco-reporter/scoverage_coverage_gaps.py modules/core/build/reports/scoverageTest/scoverage.xml
|
|
```
|
|
|
|
Expected: No uncovered lines in newly added code.
|
|
|
|
- [ ] **Step 3: Check coverage gaps for ui module**
|
|
|
|
```bash
|
|
./gradlew :modules:ui:scoverageTest && \
|
|
python jacoco-reporter/scoverage_coverage_gaps.py modules/ui/build/reports/scoverageTest/scoverage.xml
|
|
```
|
|
|
|
Expected: No uncovered lines in `TerminalUI` promotion paths.
|
|
|
|
- [ ] **Step 4: Verify NCS-10 requirements**
|
|
|
|
Check each requirement is satisfied:
|
|
|
|
- [ ] Promotion is mandatory — pawn cannot complete move until piece is chosen
|
|
- `processMove` returns `PromotionRequired`, move not recorded until `completePromotion` called
|
|
- [ ] All four promotion targets are selectable (Queen, Rook, Bishop, Knight)
|
|
- Tests: `completePromotion with rook/bishop/knight underpromotion`
|
|
- UI: TerminalUI accepts `q`, `r`, `b`, `n`
|
|
- [ ] Underpromotion works correctly
|
|
- Tests cover non-Queen piece promotion in both GameController and GameEngine
|
|
- [ ] PGN notation records the promotion piece
|
|
- `moveToAlgebraic` exports `e7e8=Q` format
|
|
- `parseAlgebraicMove` reads `e7e8=Q` and preserves it in `HistoryMove.promotionPiece`
|
|
- Board state after parsed promotion uses promoted piece for subsequent move resolution
|
|
- [ ] Promotion with capture works
|
|
- `completePromotion captures opponent piece` test
|
|
- `processMove detects pawn capturing to back rank as PromotionRequired with captured piece` test
|
|
|
|
- [ ] **Step 5: Final commit**
|
|
|
|
```bash
|
|
git add .
|
|
git commit -m "chore: verify NCS-10 pawn promotion implementation complete"
|
|
```
|