Files
NowChessSystems/docs/superpowers/plans/2026-03-29-pawn-promotion.md
T

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?