1030 lines
35 KiB
Markdown
1030 lines
35 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, record the choice in game history.
|
|
|
|
**Architecture:** Move validation detects promotion moves early. The game loop pauses and prompts for a piece choice. Once chosen, `completePromotion()` applies the move and records it with the promotion piece. PGN export/import preserves promotion notation.
|
|
|
|
**Tech Stack:** Scala 3, Quarkus (core module is non-Quarkus), scoverage for coverage, FEN for test board setup.
|
|
|
|
---
|
|
|
|
### Task 1: Extend GameHistory.Move to include promotionPiece
|
|
|
|
**Files:**
|
|
- Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala`
|
|
- Modify: `modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala`
|
|
|
|
- [ ] **Step 1: Write failing test for promotionPiece field**
|
|
|
|
Open `modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala` and add:
|
|
|
|
```scala
|
|
test("Move with promotion records the promotion piece") {
|
|
val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen))
|
|
move.promotionPiece should be (Some(PromotionPiece.Queen))
|
|
}
|
|
|
|
test("Normal move has no promotion piece") {
|
|
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None)
|
|
move.promotionPiece should be (None)
|
|
}
|
|
|
|
test("addMove with promotion stores promotionPiece") {
|
|
val history = GameHistory.empty
|
|
val newHistory = history.addMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook))
|
|
newHistory.moves should have length 1
|
|
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook))
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameHistoryTest" -v
|
|
```
|
|
|
|
Expected: FAIL with "not a member of Move" or similar (field doesn't exist yet).
|
|
|
|
- [ ] **Step 3: Extend Move case class with promotionPiece**
|
|
|
|
In `modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala`, update the `Move` case class:
|
|
|
|
```scala
|
|
case class Move(
|
|
from: Square,
|
|
to: Square,
|
|
castleSide: Option[CastleSide],
|
|
promotionPiece: Option[PromotionPiece] = None
|
|
)
|
|
```
|
|
|
|
Also add the import at the top of the file:
|
|
|
|
```scala
|
|
import de.nowchess.api.move.PromotionPiece
|
|
```
|
|
|
|
- [ ] **Step 4: Update addMove() to accept promotionPiece parameter**
|
|
|
|
In `GameHistory` case class, locate the `addMove` method and update it:
|
|
|
|
```scala
|
|
def addMove(
|
|
from: Square,
|
|
to: Square,
|
|
castleSide: Option[CastleSide] = None,
|
|
promotionPiece: Option[PromotionPiece] = None
|
|
): GameHistory =
|
|
val newMove = Move(from, to, castleSide, promotionPiece)
|
|
copy(moves = moves :+ newMove)
|
|
```
|
|
|
|
- [ ] **Step 5: Run test to verify it passes**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameHistoryTest" -v
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala \
|
|
modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala && \
|
|
git commit -m "feat: extend GameHistory.Move to track promotionPiece"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Add isPromotionMove() detection 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 for isPromotionMove()**
|
|
|
|
Open `modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala` and add:
|
|
|
|
```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)
|
|
}
|
|
|
|
test("Pawn on R7 moving backward is not a promotion move") {
|
|
val fen = "8/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.E, Rank.R5)) should be (false)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" -v
|
|
```
|
|
|
|
Expected: FAIL with "method isPromotionMove not found".
|
|
|
|
- [ ] **Step 3: Implement isPromotionMove()**
|
|
|
|
Add to `MoveValidator` object in `modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala`:
|
|
|
|
```scala
|
|
def isPromotionMove(board: Board, from: Square, to: Square): Boolean =
|
|
board.pieceAt(from) match
|
|
case Some(Piece(_, PieceType.Pawn)) =>
|
|
val toRank = to.rank
|
|
(from.rank == Rank.R7 && toRank == Rank.R8) || // White pawn to R8
|
|
(from.rank == Rank.R2 && toRank == Rank.R1) // Black pawn to R1
|
|
case _ => false
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.MoveValidatorTest" -v
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Run full core test suite to ensure no regressions**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test -v
|
|
```
|
|
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 6: 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 ADT and detect in 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: Add PromotionRequired case to MoveResult**
|
|
|
|
In `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`, add to the `MoveResult` sealed trait (after `IllegalMove` and before `Moved`):
|
|
|
|
```scala
|
|
case class PromotionRequired(
|
|
from: Square,
|
|
to: Square,
|
|
newBoard: Board,
|
|
newHistory: GameHistory,
|
|
captured: Option[Piece],
|
|
newTurn: Color
|
|
) extends MoveResult
|
|
```
|
|
|
|
- [ ] **Step 2: Write failing tests for promotion detection in processMove()**
|
|
|
|
Open `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` and add:
|
|
|
|
```scala
|
|
test("processMove detects 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 history = GameHistory.empty
|
|
|
|
val result = GameController.processMove(board, history, Color.White, "e7e8")
|
|
|
|
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
|
result match
|
|
case MoveResult.PromotionRequired(from, to, _, _, _, newTurn) =>
|
|
from should be (Square(File.E, Rank.R7))
|
|
to should be (Square(File.E, Rank.R8))
|
|
newTurn should be (Color.Black)
|
|
case _ => fail("Expected PromotionRequired")
|
|
}
|
|
|
|
test("processMove detects pawn capturing to R8 and returns PromotionRequired") {
|
|
val fen = "3q4/4P3/8/8/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
val history = GameHistory.empty
|
|
|
|
val result = GameController.processMove(board, history, Color.White, "e7d8")
|
|
|
|
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
|
result match
|
|
case MoveResult.PromotionRequired(from, to, _, _, captured, _) =>
|
|
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
|
|
case _ => fail("Expected PromotionRequired with captured piece")
|
|
}
|
|
|
|
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 history = GameHistory.empty
|
|
|
|
val result = GameController.processMove(board, history, Color.Black, "e2e1")
|
|
|
|
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run test to verify it fails**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -v
|
|
```
|
|
|
|
Expected: FAIL.
|
|
|
|
- [ ] **Step 4: Modify processMove() to detect promotion moves**
|
|
|
|
In `GameController.processMove()`, after checking `WrongColor` and before `IllegalMove` check, insert promotion detection. The full function should look like this (with promotion logic added in the inner match):
|
|
|
|
```scala
|
|
def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
|
|
raw.trim match
|
|
case "quit" | "q" =>
|
|
MoveResult.Quit
|
|
case trimmed =>
|
|
Parser.parseMove(trimmed) match
|
|
case None =>
|
|
MoveResult.InvalidFormat(trimmed)
|
|
case Some((from, to)) =>
|
|
board.pieceAt(from) match
|
|
case None =>
|
|
MoveResult.NoPiece
|
|
case Some(piece) if piece.color != turn =>
|
|
MoveResult.WrongColor
|
|
case Some(_) =>
|
|
if !MoveValidator.isLegal(board, history, from, to) then
|
|
MoveResult.IllegalMove
|
|
else if MoveValidator.isPromotionMove(board, from, to) then
|
|
// Pawn reaching back rank: return board state unchanged (pawn still on source)
|
|
val captured = board.pieceAt(to)
|
|
MoveResult.PromotionRequired(from, to, board, history, captured, turn.opposite)
|
|
else
|
|
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)
|
|
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" -v
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Run full core tests to ensure no regressions**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test -v
|
|
```
|
|
|
|
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: Implement completePromotion() function
|
|
|
|
**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 for completePromotion()**
|
|
|
|
Add to `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala`:
|
|
|
|
```scala
|
|
test("completePromotion applies pawn move and places promoted queen") {
|
|
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
val history = GameHistory.empty
|
|
|
|
val result = GameController.completePromotion(
|
|
board, history,
|
|
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 history = GameHistory.empty
|
|
|
|
val result = GameController.completePromotion(
|
|
board, history,
|
|
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 history = GameHistory.empty
|
|
|
|
val result = GameController.completePromotion(
|
|
board, history,
|
|
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 history = GameHistory.empty
|
|
|
|
val result = GameController.completePromotion(
|
|
board, history,
|
|
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 with capture") {
|
|
val fen = "3q4/4P3/8/8/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
val history = GameHistory.empty
|
|
|
|
val result = GameController.completePromotion(
|
|
board, history,
|
|
Square(File.E, Rank.R7), Square(File.D, Rank.R8),
|
|
PromotionPiece.Queen,
|
|
Color.White
|
|
)
|
|
|
|
result match
|
|
case MoveResult.Moved(newBoard, newHistory, 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 history = GameHistory.empty
|
|
|
|
val result = GameController.completePromotion(
|
|
board, history,
|
|
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 history = GameHistory.empty
|
|
|
|
// Promote to Queen on e8 gives check to black king on d8
|
|
val result = GameController.completePromotion(
|
|
board, history,
|
|
Square(File.E, Rank.R7), Square(File.E, Rank.R8),
|
|
PromotionPiece.Queen,
|
|
Color.White
|
|
)
|
|
|
|
result should matchPattern { case _: MoveResult.MovedInCheck => }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -v
|
|
```
|
|
|
|
Expected: FAIL with "method completePromotion not found".
|
|
|
|
- [ ] **Step 3: Implement completePromotion()**
|
|
|
|
Add to the `GameController` object in `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`:
|
|
|
|
```scala
|
|
def completePromotion(
|
|
board: Board,
|
|
history: GameHistory,
|
|
from: Square,
|
|
to: Square,
|
|
piece: PromotionPiece,
|
|
turn: Color
|
|
): MoveResult =
|
|
// Apply the pawn move
|
|
val (boardAfterMove, captured) = board.withMove(from, to)
|
|
|
|
// Convert PromotionPiece to PieceType
|
|
val promotedPieceType = piece match
|
|
case PromotionPiece.Queen => PieceType.Queen
|
|
case PromotionPiece.Rook => PieceType.Rook
|
|
case PromotionPiece.Bishop => PieceType.Bishop
|
|
case PromotionPiece.Knight => PieceType.Knight
|
|
|
|
// Replace the pawn with the promoted piece
|
|
val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType))
|
|
|
|
// Record the move with the promotion piece
|
|
val newHistory = history.addMove(from, to, None, Some(piece))
|
|
|
|
// Evaluate game status
|
|
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 4: Run test to verify it passes**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -v
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Run full core tests**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test -v
|
|
```
|
|
|
|
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 finalize promotion moves"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Update gameLoop to handle PromotionRequired
|
|
|
|
**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` (integration test)
|
|
|
|
- [ ] **Step 1: Write integration test for gameLoop with promotion**
|
|
|
|
Add to `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala`:
|
|
|
|
```scala
|
|
test("gameLoop integration: e7e8 followed by q returns queen on e8") {
|
|
// Test that promotion interaction works end-to-end
|
|
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
|
|
val board = FenParser.parse(fen).board
|
|
val history = GameHistory.empty
|
|
|
|
// Step 1: Player enters e7e8 (pawn promotion)
|
|
val result1 = GameController.processMove(board, history, Color.White, "e7e8")
|
|
result1 should matchPattern { case _: MoveResult.PromotionRequired => }
|
|
|
|
// Step 2: Extract from/to, then player enters q for queen
|
|
result1 match
|
|
case MoveResult.PromotionRequired(from, to, boardBeforePromotion, histBeforePromotion, _, turn) =>
|
|
val result2 = GameController.completePromotion(
|
|
boardBeforePromotion, histBeforePromotion,
|
|
from, to,
|
|
PromotionPiece.Queen,
|
|
Color.White
|
|
)
|
|
result2 should matchPattern { case _: MoveResult.Moved => }
|
|
result2 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")
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it passes**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest" -v
|
|
```
|
|
|
|
Expected: PASS (completePromotion is already implemented).
|
|
|
|
- [ ] **Step 3: Update gameLoop to handle PromotionRequired**
|
|
|
|
In `GameController.gameLoop()`, add a new case to the match statement after `IllegalMove`:
|
|
|
|
```scala
|
|
case MoveResult.PromotionRequired(from, to, boardBeforePromotion, histBeforePromotion, captured, currentTurn) =>
|
|
println("Promote to: (q/r/b/n)? ")
|
|
val pieceInput = Option(StdIn.readLine()).getOrElse("").trim.toLowerCase
|
|
val piece: Option[PromotionPiece] = pieceInput match
|
|
case "q" => Some(PromotionPiece.Queen)
|
|
case "r" => Some(PromotionPiece.Rook)
|
|
case "b" => Some(PromotionPiece.Bishop)
|
|
case "n" => Some(PromotionPiece.Knight)
|
|
case _ => None
|
|
|
|
piece match
|
|
case None =>
|
|
println("Invalid choice. Enter q, r, b, or n.")
|
|
gameLoop(board, history, turn) // retry promotion choice
|
|
case Some(p) =>
|
|
completePromotion(boardBeforePromotion, histBeforePromotion, from, to, p, Color.White) match
|
|
case MoveResult.Moved(newBoard, newHistory, _, newTurn) =>
|
|
if captured.isDefined then
|
|
println(s"${turn.label} captures ${captured.get.color.label} ${captured.get.pieceType.label} on $to and promotes to ${p.toString.toLowerCase}")
|
|
else
|
|
println(s"${turn.label} promotes pawn to ${p.toString.toLowerCase}")
|
|
gameLoop(newBoard, newHistory, newTurn)
|
|
case MoveResult.MovedInCheck(newBoard, newHistory, _, newTurn) =>
|
|
if captured.isDefined then
|
|
println(s"${turn.label} captures ${captured.get.color.label} ${captured.get.pieceType.label} on $to and promotes to ${p.toString.toLowerCase}")
|
|
else
|
|
println(s"${turn.label} promotes pawn to ${p.toString.toLowerCase}")
|
|
println(s"${newTurn.label} is in check!")
|
|
gameLoop(newBoard, newHistory, newTurn)
|
|
case MoveResult.Checkmate(winner) =>
|
|
println(s"Checkmate! ${winner.label} wins.")
|
|
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
|
case MoveResult.Stalemate =>
|
|
println("Stalemate! The game is a draw.")
|
|
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
|
case _ =>
|
|
// should not happen
|
|
println("Error completing promotion.")
|
|
gameLoop(board, history, turn)
|
|
```
|
|
|
|
Wait, there's an issue here. The `PromotionRequired` returns `Color.White` as `turn`, but inside the match `turn` variable refers to the loop's current turn. Let me fix:
|
|
|
|
```scala
|
|
case MoveResult.PromotionRequired(from, to, boardBeforePromotion, histBeforePromotion, captured, _) =>
|
|
println("Promote to: (q/r/b/n)? ")
|
|
val pieceInput = Option(StdIn.readLine()).getOrElse("").trim.toLowerCase
|
|
val piece: Option[PromotionPiece] = pieceInput match
|
|
case "q" => Some(PromotionPiece.Queen)
|
|
case "r" => Some(PromotionPiece.Rook)
|
|
case "b" => Some(PromotionPiece.Bishop)
|
|
case "n" => Some(PromotionPiece.Knight)
|
|
case _ => None
|
|
|
|
piece match
|
|
case None =>
|
|
println("Invalid choice. Enter q, r, b, or n.")
|
|
gameLoop(board, history, turn) // retry promotion choice
|
|
case Some(p) =>
|
|
completePromotion(boardBeforePromotion, histBeforePromotion, from, to, p, turn) match
|
|
case MoveResult.Moved(newBoard, newHistory, _, newTurn) =>
|
|
if captured.isDefined then
|
|
println(s"${turn.label} captures ${captured.get.color.label} ${captured.get.pieceType.label} on $to and promotes to ${p.toString.toLowerCase}")
|
|
else
|
|
println(s"${turn.label} promotes pawn to ${p.toString.toLowerCase}")
|
|
gameLoop(newBoard, newHistory, newTurn)
|
|
case MoveResult.MovedInCheck(newBoard, newHistory, _, newTurn) =>
|
|
if captured.isDefined then
|
|
println(s"${turn.label} captures ${captured.get.color.label} ${captured.get.pieceType.label} on $to and promotes to ${p.toString.toLowerCase}")
|
|
else
|
|
println(s"${turn.label} promotes pawn to ${p.toString.toLowerCase}")
|
|
println(s"${newTurn.label} is in check!")
|
|
gameLoop(newBoard, newHistory, newTurn)
|
|
case MoveResult.Checkmate(winner) =>
|
|
println(s"Checkmate! ${winner.label} wins.")
|
|
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
|
case MoveResult.Stalemate =>
|
|
println("Stalemate! The game is a draw.")
|
|
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
|
case _ =>
|
|
println("Error completing promotion.")
|
|
gameLoop(board, history, turn)
|
|
```
|
|
|
|
- [ ] **Step 4: Run full core tests**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test -v
|
|
```
|
|
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 5: Compile check**
|
|
|
|
```bash
|
|
./gradlew :modules:core:build -v
|
|
```
|
|
|
|
Expected: Build succeeds.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala && \
|
|
git commit -m "feat: update gameLoop to prompt for promotion piece and complete promotion"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Add 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 for promotion notation export**
|
|
|
|
Open `modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala` and add:
|
|
|
|
```scala
|
|
test("moveToSan exports promotion notation with Queen") {
|
|
val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen))
|
|
PgnExporter.moveToSan(move) should be ("e7e8=Q")
|
|
}
|
|
|
|
test("moveToSan exports promotion notation with Rook") {
|
|
val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook))
|
|
PgnExporter.moveToSan(move) should be ("e7e8=R")
|
|
}
|
|
|
|
test("moveToSan exports promotion notation with Bishop") {
|
|
val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Bishop))
|
|
PgnExporter.moveToSan(move) should be ("e7e8=B")
|
|
}
|
|
|
|
test("moveToSan exports promotion notation with Knight") {
|
|
val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Knight))
|
|
PgnExporter.moveToSan(move) should be ("e7e8=N")
|
|
}
|
|
|
|
test("moveToSan exports normal move without promotion") {
|
|
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None)
|
|
PgnExporter.moveToSan(move) should be ("e2e4")
|
|
}
|
|
|
|
test("moveToSan exports capture without promotion") {
|
|
val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), None, None)
|
|
PgnExporter.moveToSan(move) should be ("e5d6")
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnExporterTest" -v
|
|
```
|
|
|
|
Expected: FAIL (moveToSan doesn't handle promotion yet).
|
|
|
|
- [ ] **Step 3: Implement promotion export in PgnExporter**
|
|
|
|
Open `modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala` and update the `moveToSan` function:
|
|
|
|
```scala
|
|
def moveToSan(move: Move): String =
|
|
val baseMove = s"${move.from}${move.to}"
|
|
move.promotionPiece match
|
|
case Some(piece) =>
|
|
val pieceLetter = piece match
|
|
case PromotionPiece.Queen => "Q"
|
|
case PromotionPiece.Rook => "R"
|
|
case PromotionPiece.Bishop => "B"
|
|
case PromotionPiece.Knight => "N"
|
|
s"$baseMove=$pieceLetter"
|
|
case None => baseMove
|
|
```
|
|
|
|
(Assuming `moveToSan` currently exists; if not, create it as shown above.)
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnExporterTest" -v
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Run full core tests**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test -v
|
|
```
|
|
|
|
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"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Add 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 for promotion notation parsing**
|
|
|
|
Open `modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala` and add:
|
|
|
|
```scala
|
|
test("parseMove parses promotion to Queen") {
|
|
val result = PgnParser.parseMove("e7e8=Q")
|
|
result should be (Some((Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Queen))))
|
|
}
|
|
|
|
test("parseMove parses promotion to Rook") {
|
|
val result = PgnParser.parseMove("e7e8=R")
|
|
result should be (Some((Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Rook))))
|
|
}
|
|
|
|
test("parseMove parses promotion to Bishop") {
|
|
val result = PgnParser.parseMove("e7e8=B")
|
|
result should be (Some((Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Bishop))))
|
|
}
|
|
|
|
test("parseMove parses promotion to Knight") {
|
|
val result = PgnParser.parseMove("e7e8=N")
|
|
result should be (Some((Square(File.E, Rank.R7), Square(File.E, Rank.R8), Some(PromotionPiece.Knight))))
|
|
}
|
|
|
|
test("parseMove parses normal move without promotion") {
|
|
val result = PgnParser.parseMove("e2e4")
|
|
result should be (Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4), None)))
|
|
}
|
|
|
|
test("parseMove rejects invalid promotion piece") {
|
|
val result = PgnParser.parseMove("e7e8=X")
|
|
result should be (None)
|
|
}
|
|
```
|
|
|
|
Note: Update the return type of `parseMove` to include `Option[PromotionPiece]`:
|
|
|
|
```scala
|
|
def parseMove(moveStr: String): Option[(Square, Square, Option[PromotionPiece])]
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnParserTest" -v
|
|
```
|
|
|
|
Expected: FAIL.
|
|
|
|
- [ ] **Step 3: Implement promotion parsing in PgnParser**
|
|
|
|
Open `modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala` and update (or create) the `parseMove` function:
|
|
|
|
```scala
|
|
def parseMove(moveStr: String): Option[(Square, Square, Option[PromotionPiece])] =
|
|
val pattern = """^([a-h])([1-8])([a-h])([1-8])(?:=([QRBN]))?$""".r
|
|
moveStr.trim match
|
|
case pattern(fromFile, fromRank, toFile, toRank, promotionPiece) =>
|
|
val from = Square(File.fromChar(fromFile.head), Rank.fromOrdinal(fromRank.toInt - 1))
|
|
val to = Square(File.fromChar(toFile.head), Rank.fromOrdinal(toRank.toInt - 1))
|
|
val promotion = Option(promotionPiece).flatMap { p =>
|
|
p match
|
|
case "Q" => Some(PromotionPiece.Queen)
|
|
case "R" => Some(PromotionPiece.Rook)
|
|
case "B" => Some(PromotionPiece.Bishop)
|
|
case "N" => Some(PromotionPiece.Knight)
|
|
case _ => None
|
|
}
|
|
Some((from, to, promotion))
|
|
case _ => None
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test --tests "de.nowchess.chess.notation.PgnParserTest" -v
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Run full core tests**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test -v
|
|
```
|
|
|
|
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 8: Final integration and coverage verification
|
|
|
|
**Files:**
|
|
- All modified files from previous tasks
|
|
- Coverage report
|
|
|
|
- [ ] **Step 1: Run full test suite**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test -v
|
|
```
|
|
|
|
Expected: All tests pass, 100% line coverage for new code.
|
|
|
|
- [ ] **Step 2: Check coverage gaps**
|
|
|
|
```bash
|
|
./gradlew :modules:core:test && \
|
|
python jacoco-reporter/scoverage_coverage_gaps.py modules/core/build/reports/scoverageTest/scoverage.xml
|
|
```
|
|
|
|
Expected: No uncovered lines in new code.
|
|
|
|
- [ ] **Step 3: Build all modules**
|
|
|
|
```bash
|
|
./gradlew build -v
|
|
```
|
|
|
|
Expected: Full build passes.
|
|
|
|
- [ ] **Step 4: Manual gameplay test (optional)**
|
|
|
|
Run the application and test promotion manually:
|
|
|
|
```bash
|
|
./gradlew :modules:core:run
|
|
```
|
|
|
|
At the prompt, set up a promotion scenario and verify:
|
|
- Move pawn to back rank (e.g. `e7e8`)
|
|
- System prompts `"Promote to: (q/r/b/n)?"`
|
|
- Enter choice (e.g. `q`)
|
|
- Pawn is replaced with chosen piece
|
|
- Game continues normally
|
|
|
|
- [ ] **Step 5: Final build and test**
|
|
|
|
```bash
|
|
./gradlew build -v
|
|
```
|
|
|
|
Expected: Full green build.
|
|
|
|
- [ ] **Step 6: Verify requirements from NCS-10 DoD**
|
|
|
|
Check each requirement is satisfied:
|
|
|
|
- [x] Promotion is mandatory — move is not completed until piece is chosen
|
|
- `processMove` returns `PromotionRequired`, blocking move until `completePromotion` is called
|
|
|
|
- [x] All four promotion targets are selectable
|
|
- Tests cover Queen, Rook, Bishop, Knight
|
|
- `gameLoop` accepts `q`, `r`, `b`, `n`
|
|
|
|
- [x] Underpromotion works correctly
|
|
- Tests verify promotion to non-Queen pieces
|
|
|
|
- [x] PGN notation records the promotion piece
|
|
- `moveToSan` exports `e8=Q` format
|
|
- `parseMove` parses `e8=Q` format
|
|
|
|
- [x] Tests cover promotion to each piece, capture + promotion, underpromotion
|
|
- `MoveValidatorTest`: promotion detection
|
|
- `GameControllerTest`: complete promotion flow, each piece type, captures, underpromotion
|
|
- `PgnExporterTest`: notation export
|
|
- `PgnParserTest`: notation parsing
|
|
|
|
- [ ] **Step 7: Commit final integration verification**
|
|
|
|
```bash
|
|
git log --oneline -8
|
|
```
|
|
|
|
Verify the 7 commits for promotion tasks are present.
|
|
|
|
---
|
|
|
|
## Implementation Summary
|
|
|
|
| Task | Component | Changes |
|
|
|------|-----------|---------|
|
|
| 1 | GameHistory | Extended `Move` with `promotionPiece: Option[PromotionPiece]` |
|
|
| 2 | MoveValidator | Added `isPromotionMove()` detection |
|
|
| 3 | GameController | Added `PromotionRequired` result, detection in `processMove()` |
|
|
| 4 | GameController | Added `completePromotion()` to apply move and record promotion |
|
|
| 5 | GameController | Updated `gameLoop()` to prompt and handle promotion |
|
|
| 6 | PgnExporter | Export promotion notation (e.g. `e8=Q`) |
|
|
| 7 | PgnParser | Parse promotion notation (e.g. `e8=Q`) |
|
|
| 8 | Integration | Full test suite passes, 100% coverage, requirements verified |
|
|
|
|
---
|
|
|
|
Plan complete and saved to `docs/superpowers/plans/2026-03-29-pawn-promotion.md`.
|
|
|
|
**Two execution options:**
|
|
|
|
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration
|
|
|
|
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints
|
|
|
|
Which approach? |